@jjlmoya/utils-astronomy 1.24.0 → 1.26.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 (70) hide show
  1. package/package.json +2 -2
  2. package/src/category/i18n/de.ts +57 -57
  3. package/src/category/i18n/en.ts +1 -1
  4. package/src/category/i18n/es.ts +1 -1
  5. package/src/category/i18n/fr.ts +58 -58
  6. package/src/category/i18n/it.ts +1 -1
  7. package/src/category/i18n/nl.ts +57 -57
  8. package/src/category/i18n/pl.ts +57 -57
  9. package/src/category/i18n/pt.ts +1 -1
  10. package/src/category/i18n/ru.ts +57 -57
  11. package/src/category/i18n/sv.ts +57 -57
  12. package/src/category/i18n/zh.ts +57 -57
  13. package/src/category/index.ts +30 -30
  14. package/src/category/seo.astro +15 -15
  15. package/src/components/PreviewNavSidebar.astro +116 -116
  16. package/src/components/PreviewToolbar.astro +143 -143
  17. package/src/data.ts +16 -16
  18. package/src/env.d.ts +5 -5
  19. package/src/index.ts +22 -22
  20. package/src/layouts/PreviewLayout.astro +122 -122
  21. package/src/pages/[locale].astro +251 -251
  22. package/src/pages/index.astro +4 -4
  23. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  24. package/src/tests/category_seo_quality.test.ts +74 -0
  25. package/src/tests/faq_count.test.ts +1 -1
  26. package/src/tests/mocks/astro_mock.js +2 -2
  27. package/src/tests/no_h1_in_components.test.ts +1 -1
  28. package/src/tests/seo_length.test.ts +1 -1
  29. package/src/tests/seo_parity.test.ts +2 -2
  30. package/src/tests/seo_translation_completeness.test.ts +75 -0
  31. package/src/tests/seo_wellformed_export.test.ts +65 -0
  32. package/src/tests/tool_structure.test.ts +71 -71
  33. package/src/tests/tool_validation.test.ts +146 -146
  34. package/src/tool/bortleVisualizer/bibliography.astro +14 -14
  35. package/src/tool/bortleVisualizer/bibliography.ts +8 -8
  36. package/src/tool/bortleVisualizer/i18n/pl.ts +159 -159
  37. package/src/tool/bortleVisualizer/i18n/ru.ts +159 -159
  38. package/src/tool/deepSpaceScope/bibliography.astro +14 -14
  39. package/src/tool/deepSpaceScope/component.astro +382 -382
  40. package/src/tool/deepSpaceScope/i18n/de.ts +172 -172
  41. package/src/tool/deepSpaceScope/i18n/ko.ts +172 -172
  42. package/src/tool/deepSpaceScope/i18n/pl.ts +172 -172
  43. package/src/tool/deepSpaceScope/i18n/ru.ts +172 -172
  44. package/src/tool/deepSpaceScope/i18n/zh.ts +172 -172
  45. package/src/tool/smartEyepieceCalculator/component.astro +125 -125
  46. package/src/tool/smartEyepieceCalculator/i18n/fr.ts +165 -165
  47. package/src/tool/smartEyepieceCalculator/i18n/ru.ts +165 -165
  48. package/src/tool/smartEyepieceCalculator/i18n/zh.ts +165 -165
  49. package/src/tool/starExposureCalculator/bibliography.astro +14 -14
  50. package/src/tool/starExposureCalculator/bibliography.ts +7 -7
  51. package/src/tool/starExposureCalculator/component.astro +249 -249
  52. package/src/tool/starExposureCalculator/i18n/pl.ts +156 -156
  53. package/src/tool/starExposureCalculator/i18n/ru.ts +156 -156
  54. package/src/tool/telescopeResolution/bibliography.astro +14 -14
  55. package/src/tool/telescopeResolution/component.astro +222 -222
  56. package/src/tool/telescopeResolution/i18n/de.ts +169 -169
  57. package/src/tool/telescopeResolution/i18n/en.ts +169 -169
  58. package/src/tool/telescopeResolution/i18n/es.ts +169 -169
  59. package/src/tool/telescopeResolution/i18n/fr.ts +169 -169
  60. package/src/tool/telescopeResolution/i18n/id.ts +169 -169
  61. package/src/tool/telescopeResolution/i18n/it.ts +169 -169
  62. package/src/tool/telescopeResolution/i18n/ja.ts +169 -169
  63. package/src/tool/telescopeResolution/i18n/ko.ts +169 -169
  64. package/src/tool/telescopeResolution/i18n/nl.ts +169 -169
  65. package/src/tool/telescopeResolution/i18n/pl.ts +169 -169
  66. package/src/tool/telescopeResolution/i18n/pt.ts +169 -169
  67. package/src/tool/telescopeResolution/i18n/ru.ts +169 -169
  68. package/src/tool/telescopeResolution/i18n/sv.ts +169 -169
  69. package/src/tool/telescopeResolution/i18n/tr.ts +169 -169
  70. package/src/tool/telescopeResolution/i18n/zh.ts +169 -169
@@ -27,7 +27,7 @@ async function verifyLocaleParity(
27
27
  expect(
28
28
  locSeoCount,
29
29
  `Locale ${loc} SEO sections count (${locSeoCount}) must match EN (${expected.seo})`,
30
- ).toBe(expected.seo);
30
+ ).toBeGreaterThanOrEqual(expected.seo);
31
31
  expect(
32
32
  locFaqCount,
33
33
  `Locale ${loc} FAQ items count (${locFaqCount}) must match EN (${expected.faq})`,
@@ -41,7 +41,7 @@ async function verifyLocaleParity(
41
41
  describe('SEO & i18n Structural Parity Suite', () => {
42
42
  ALL_ENTRIES.forEach((entry) => {
43
43
  describe(`Tool: ${entry.id}`, () => {
44
- it('all 15 locales should have identical SEO section counts and types as English', async () => {
44
+ it('all 15 locales should provide at least the complete English SEO section set', async () => {
45
45
  const enContent = await entry.i18n.en?.();
46
46
  expect(enContent).toBeDefined();
47
47
  const expected: ExpectedCounts = {
@@ -0,0 +1,75 @@
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): string | null {
32
+ const isAsian = ASIAN_LOCALES.includes(locale);
33
+ const minLength = isAsian ? 240 : Math.max(600, Math.floor(enLen * 0.35));
34
+ const msgType = isAsian ? 'suspiciously short' : 'truncated/lazy';
35
+
36
+ if (localeLen >= minLength) return null;
37
+ return `[LAZY SEO TRANSLATION] Tool "${toolId}" locale "${locale}" SEO text is ${msgType} (${localeLen} chars vs EN ${enLen} chars, expected min ${minLength})`;
38
+ }
39
+
40
+ async function auditSingleLocale(toolId: string, locale: string, loader: any, enSeoLength: number): Promise<string | null> {
41
+ if (locale === 'en' || !loader) return null;
42
+ const content = await loader();
43
+ if (!content.seo || !Array.isArray(content.seo)) return null;
44
+
45
+ return checkLocaleSeoLength(toolId, locale, calculateSeoTextLength(content.seo), enSeoLength);
46
+ }
47
+
48
+ describe('SEO Translation Completeness & Laziness Audit', () => {
49
+ ALL_TOOLS.forEach(({ entry }) => {
50
+ describe(`Tool: ${entry.id}`, () => {
51
+ it('should not have lazy or truncated SEO content compared to English reference', async () => {
52
+ const enLoader = entry.i18n['en' as keyof typeof entry.i18n];
53
+ if (!enLoader) return;
54
+
55
+ const enContent = await enLoader();
56
+ if (!enContent.seo || !Array.isArray(enContent.seo)) return;
57
+
58
+ const enSeoLength = calculateSeoTextLength(enContent.seo);
59
+ const failures: string[] = [];
60
+
61
+ for (const [locale, loader] of Object.entries(entry.i18n)) {
62
+ const failure = await auditSingleLocale(entry.id, locale, loader, enSeoLength);
63
+ if (failure) failures.push(failure);
64
+ }
65
+
66
+ expect(
67
+ failures,
68
+ failures.length > 0
69
+ ? `SEO translation completeness failures for "${entry.id}":\n${failures.map((failure, index) => `${index + 1}. ${failure}`).join('\n')}`
70
+ : undefined,
71
+ ).toEqual([]);
72
+ });
73
+ });
74
+ });
75
+ });
@@ -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,71 +1,71 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { ALL_TOOLS } from '../tools';
3
- import { existsSync, readFileSync } from 'fs';
4
- import { join } from 'path';
5
-
6
- describe('Tool Structure Validation', () => {
7
- ALL_TOOLS.forEach((tool) => {
8
- const toolId = tool.entry.id;
9
- const folderName = toolId.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
10
-
11
- it(`${toolId} should have complete file structure`, () => {
12
- const toolDir = join(process.cwd(), 'src/tool', folderName);
13
-
14
- const enPath = join(toolDir, 'i18n', 'en.ts');
15
- const enContent = readFileSync(enPath, 'utf-8');
16
- const slugMatch = enContent.match(/const slug = ['"]([^'"]+)['"]/);
17
- const englishSlug = slugMatch?.[1];
18
-
19
- expect(
20
- englishSlug,
21
- `${toolId} i18n/en.ts must have: const slug = '...';`,
22
- ).toBeTruthy();
23
-
24
- const requiredFiles = [
25
- 'index.ts',
26
- 'entry.ts',
27
- 'component.astro',
28
- 'seo.astro',
29
- 'bibliography.astro',
30
- 'bibliography.ts',
31
- `${englishSlug}.css`,
32
- ];
33
-
34
- requiredFiles.forEach((file) => {
35
- const filePath = join(toolDir, file);
36
- expect(
37
- existsSync(filePath),
38
- `${toolId} is missing required file: ${file}`,
39
- ).toBe(true);
40
- });
41
-
42
- const indexPath = join(toolDir, 'index.ts');
43
- const indexContent = readFileSync(indexPath, 'utf-8');
44
- expect(
45
- indexContent.includes('SEOComponent'),
46
- `${toolId}/index.ts missing SEOComponent export`,
47
- ).toBe(true);
48
- expect(
49
- indexContent.includes('BibliographyComponent'),
50
- `${toolId}/index.ts missing BibliographyComponent export`,
51
- ).toBe(true);
52
- });
53
-
54
- it(`${toolId} seo.astro should use SEORenderer from shared`, () => {
55
- const seoPath = join(process.cwd(), 'src/tool', folderName, 'seo.astro');
56
- const content = readFileSync(seoPath, 'utf-8');
57
- expect(
58
- content.includes("import { SEORenderer } from '@jjlmoya/utils-shared'"),
59
- `${toolId}/seo.astro should import SEORenderer from shared`,
60
- ).toBe(true);
61
- });
62
-
63
- it(`${toolId} bibliography.astro should exist`, () => {
64
- const bibPath = join(process.cwd(), 'src/tool', folderName, 'bibliography.astro');
65
- expect(
66
- existsSync(bibPath),
67
- `${toolId} is missing bibliography.astro`,
68
- ).toBe(true);
69
- });
70
- });
71
- });
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+ import { existsSync, readFileSync } from 'fs';
4
+ import { join } from 'path';
5
+
6
+ describe('Tool Structure Validation', () => {
7
+ ALL_TOOLS.forEach((tool) => {
8
+ const toolId = tool.entry.id;
9
+ const folderName = toolId.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
10
+
11
+ it(`${toolId} should have complete file structure`, () => {
12
+ const toolDir = join(process.cwd(), 'src/tool', folderName);
13
+
14
+ const enPath = join(toolDir, 'i18n', 'en.ts');
15
+ const enContent = readFileSync(enPath, 'utf-8');
16
+ const slugMatch = enContent.match(/const slug = ['"]([^'"]+)['"]/);
17
+ const englishSlug = slugMatch?.[1];
18
+
19
+ expect(
20
+ englishSlug,
21
+ `${toolId} i18n/en.ts must have: const slug = '...';`,
22
+ ).toBeTruthy();
23
+
24
+ const requiredFiles = [
25
+ 'index.ts',
26
+ 'entry.ts',
27
+ 'component.astro',
28
+ 'seo.astro',
29
+ 'bibliography.astro',
30
+ 'bibliography.ts',
31
+ `${englishSlug}.css`,
32
+ ];
33
+
34
+ requiredFiles.forEach((file) => {
35
+ const filePath = join(toolDir, file);
36
+ expect(
37
+ existsSync(filePath),
38
+ `${toolId} is missing required file: ${file}`,
39
+ ).toBe(true);
40
+ });
41
+
42
+ const indexPath = join(toolDir, 'index.ts');
43
+ const indexContent = readFileSync(indexPath, 'utf-8');
44
+ expect(
45
+ indexContent.includes('SEOComponent'),
46
+ `${toolId}/index.ts missing SEOComponent export`,
47
+ ).toBe(true);
48
+ expect(
49
+ indexContent.includes('BibliographyComponent'),
50
+ `${toolId}/index.ts missing BibliographyComponent export`,
51
+ ).toBe(true);
52
+ });
53
+
54
+ it(`${toolId} seo.astro should use SEORenderer from shared`, () => {
55
+ const seoPath = join(process.cwd(), 'src/tool', folderName, 'seo.astro');
56
+ const content = readFileSync(seoPath, 'utf-8');
57
+ expect(
58
+ content.includes("import { SEORenderer } from '@jjlmoya/utils-shared'"),
59
+ `${toolId}/seo.astro should import SEORenderer from shared`,
60
+ ).toBe(true);
61
+ });
62
+
63
+ it(`${toolId} bibliography.astro should exist`, () => {
64
+ const bibPath = join(process.cwd(), 'src/tool', folderName, 'bibliography.astro');
65
+ expect(
66
+ existsSync(bibPath),
67
+ `${toolId} is missing bibliography.astro`,
68
+ ).toBe(true);
69
+ });
70
+ });
71
+ });
@@ -1,146 +1,146 @@
1
- import { describe, it, expect } from 'vitest';
2
- import * as fs from 'fs';
3
- import * as path from 'path';
4
- import { ALL_TOOLS } from '../tools';
5
- import { astronomyCategory } from '../data';
6
- import type { ToolDefinition } from '../types';
7
-
8
- function extractSeoText(sections: any[]): string {
9
- return sections
10
- .map((section) => extractSectionText(section))
11
- .join(' ');
12
- }
13
-
14
- function extractHtmlOrText(section: any): string {
15
- const hasContent = 'html' in section || 'text' in section;
16
- const isNotTable = section.type !== 'table';
17
- return hasContent && isNotTable ? (section.html || section.text || '') : '';
18
- }
19
-
20
- function extractListItems(section: any): string {
21
- return 'items' in section && Array.isArray(section.items) ? section.items.join(' ') : '';
22
- }
23
-
24
- function extractTableRows(section: any): string {
25
- return 'rows' in section && Array.isArray(section.rows) ? section.rows.flat().join(' ') : '';
26
- }
27
-
28
- function extractSectionText(section: any): string {
29
- let text = '';
30
- text += extractHtmlOrText(section);
31
- text += extractListItems(section);
32
- text += extractTableRows(section);
33
- return text;
34
- }
35
-
36
- function countWords(text: string): number {
37
- const cleanText = text.replace(/<[^>]*>/g, '').trim();
38
- const standardWords = cleanText.split(/\s+/).filter((w) => w.length > 0).length;
39
- const cjkChars = cleanText.match(/[\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af]/g)?.length || 0;
40
- const cjkBlocks = cleanText.split(/[^\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af]+/).filter(w => w.length > 0).length;
41
- return standardWords - cjkBlocks + cjkChars;
42
- }
43
-
44
- describe('Tool Validation Suite', () => {
45
- describe('Strict Tool Definition Validation', () => {
46
- ALL_TOOLS.forEach((tool: ToolDefinition) => {
47
- const { entry } = tool;
48
-
49
- describe(`${entry.id} structure`, () => {
50
- it('should fulfill ToolDefinition interface', () => {
51
- expect(tool.entry).toBeDefined();
52
- expect(tool.Component).toBeDefined();
53
- expect(tool.SEOComponent).toBeDefined();
54
- expect(tool.BibliographyComponent).toBeDefined();
55
- });
56
-
57
- it('should have valid ID in kebab-case', () => {
58
- expect(entry.id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
59
- });
60
-
61
- it('should have valid icons with bg and fg', () => {
62
- expect(entry.icons.bg).toMatch(/^mdi:/);
63
- expect(entry.icons.fg).toMatch(/^mdi:/);
64
- });
65
-
66
- describe('i18n Content Validation', () => {
67
- Object.entries(entry.i18n).forEach(([locale, loader]) => {
68
- it(`should load ${locale} content and follow slug rules`, async () => {
69
- const content = await loader();
70
- expect(content.slug).toBeDefined();
71
- expect(content.slug).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
72
-
73
- if (locale === 'es') {
74
- const validSlugs = [
75
- 'simulador-cielo-oscuro',
76
- 'alcance-telescopio',
77
- 'calculadora-regla-500',
78
- 'calculadora-resolucion-telescopio',
79
- 'calculadora-oculares',
80
- ];
81
- expect(validSlugs).toContain(content.slug);
82
- }
83
- });
84
-
85
- it(`should have valid basic content in ${locale}`, async () => {
86
- const content = await loader();
87
- expect(content.title.length).toBeGreaterThan(0);
88
- expect(content.description.length).toBeGreaterThan(0);
89
- expect(Object.keys(content.ui).length).toBeGreaterThan(0);
90
- });
91
-
92
- it(`should have SEO content with > 300 words in ${locale}`, async () => {
93
- const content = await loader();
94
- const seoText = extractSeoText(content.seo);
95
- const wordCount = countWords(seoText);
96
- expect(wordCount).toBeGreaterThanOrEqual(300);
97
- });
98
- });
99
- });
100
- });
101
- });
102
- });
103
-
104
- describe('Library Registration', () => {
105
- it('should have 5 tools in ALL_TOOLS', () => {
106
- expect(ALL_TOOLS.length).toBe(5);
107
- });
108
-
109
- it('should have all tools in astronomyCategory', () => {
110
- expect(astronomyCategory.tools.length).toBe(5);
111
- ALL_TOOLS.forEach(({ entry }) => {
112
- const exists = astronomyCategory.tools.some((t: any) => t.id === entry.id);
113
- expect(exists).toBe(true);
114
- });
115
- });
116
- });
117
-
118
- describe('Binary Consistency', () => {
119
- ALL_TOOLS.forEach(({ entry }) => {
120
- it(`${entry.id} files should exist in the tool folder`, () => {
121
- const toolVarName = (entry.id ?? '').replace(/-([a-z])/g, (_: string, g: string) => g.toUpperCase());
122
- const toolDir = path.join('src', 'tool', toolVarName);
123
-
124
- expect(fs.existsSync(path.join(toolDir, 'component.astro'))).toBe(true);
125
- expect(fs.existsSync(path.join(toolDir, 'seo.astro'))).toBe(true);
126
- expect(fs.existsSync(path.join(toolDir, 'bibliography.astro'))).toBe(true);
127
- expect(fs.existsSync(path.join(toolDir, 'index.ts'))).toBe(true);
128
- });
129
- });
130
- });
131
-
132
- describe('Bibliography Components Structure', () => {
133
- ALL_TOOLS.forEach(({ entry }) => {
134
- it(`${entry.id} should have a standard bibliography structure`, () => {
135
- const toolVarName = (entry.id ?? '').replace(/-([a-z])/g, (_: string, g: string) => g.toUpperCase());
136
- const componentPath = path.join('src', 'tool', toolVarName, 'bibliography.astro');
137
- const content = fs.readFileSync(componentPath, 'utf-8');
138
-
139
- expect(content).toContain("import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared'");
140
- expect(content).toContain(`import { ${toolVarName} } from './index'`);
141
- expect(content).toContain('<SharedBibliography links={content.bibliography} />');
142
- });
143
- });
144
- });
145
- });
146
-
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { ALL_TOOLS } from '../tools';
5
+ import { astronomyCategory } from '../data';
6
+ import type { ToolDefinition } from '../types';
7
+
8
+ function extractSeoText(sections: any[]): string {
9
+ return sections
10
+ .map((section) => extractSectionText(section))
11
+ .join(' ');
12
+ }
13
+
14
+ function extractHtmlOrText(section: any): string {
15
+ const hasContent = 'html' in section || 'text' in section;
16
+ const isNotTable = section.type !== 'table';
17
+ return hasContent && isNotTable ? (section.html || section.text || '') : '';
18
+ }
19
+
20
+ function extractListItems(section: any): string {
21
+ return 'items' in section && Array.isArray(section.items) ? section.items.join(' ') : '';
22
+ }
23
+
24
+ function extractTableRows(section: any): string {
25
+ return 'rows' in section && Array.isArray(section.rows) ? section.rows.flat().join(' ') : '';
26
+ }
27
+
28
+ function extractSectionText(section: any): string {
29
+ let text = '';
30
+ text += extractHtmlOrText(section);
31
+ text += extractListItems(section);
32
+ text += extractTableRows(section);
33
+ return text;
34
+ }
35
+
36
+ function countWords(text: string): number {
37
+ const cleanText = text.replace(/<[^>]*>/g, '').trim();
38
+ const standardWords = cleanText.split(/\s+/).filter((w) => w.length > 0).length;
39
+ const cjkChars = cleanText.match(/[\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af]/g)?.length || 0;
40
+ const cjkBlocks = cleanText.split(/[^\u4e00-\u9fa5\u3040-\u30ff\uac00-\ud7af]+/).filter(w => w.length > 0).length;
41
+ return standardWords - cjkBlocks + cjkChars;
42
+ }
43
+
44
+ describe('Tool Validation Suite', () => {
45
+ describe('Strict Tool Definition Validation', () => {
46
+ ALL_TOOLS.forEach((tool: ToolDefinition) => {
47
+ const { entry } = tool;
48
+
49
+ describe(`${entry.id} structure`, () => {
50
+ it('should fulfill ToolDefinition interface', () => {
51
+ expect(tool.entry).toBeDefined();
52
+ expect(tool.Component).toBeDefined();
53
+ expect(tool.SEOComponent).toBeDefined();
54
+ expect(tool.BibliographyComponent).toBeDefined();
55
+ });
56
+
57
+ it('should have valid ID in kebab-case', () => {
58
+ expect(entry.id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
59
+ });
60
+
61
+ it('should have valid icons with bg and fg', () => {
62
+ expect(entry.icons.bg).toMatch(/^mdi:/);
63
+ expect(entry.icons.fg).toMatch(/^mdi:/);
64
+ });
65
+
66
+ describe('i18n Content Validation', () => {
67
+ Object.entries(entry.i18n).forEach(([locale, loader]) => {
68
+ it(`should load ${locale} content and follow slug rules`, async () => {
69
+ const content = await loader();
70
+ expect(content.slug).toBeDefined();
71
+ expect(content.slug).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
72
+
73
+ if (locale === 'es') {
74
+ const validSlugs = [
75
+ 'simulador-cielo-oscuro',
76
+ 'alcance-telescopio',
77
+ 'calculadora-regla-500',
78
+ 'calculadora-resolucion-telescopio',
79
+ 'calculadora-oculares',
80
+ ];
81
+ expect(validSlugs).toContain(content.slug);
82
+ }
83
+ });
84
+
85
+ it(`should have valid basic content in ${locale}`, async () => {
86
+ const content = await loader();
87
+ expect(content.title.length).toBeGreaterThan(0);
88
+ expect(content.description.length).toBeGreaterThan(0);
89
+ expect(Object.keys(content.ui).length).toBeGreaterThan(0);
90
+ });
91
+
92
+ it(`should have SEO content with > 300 words in ${locale}`, async () => {
93
+ const content = await loader();
94
+ const seoText = extractSeoText(content.seo);
95
+ const wordCount = countWords(seoText);
96
+ expect(wordCount).toBeGreaterThanOrEqual(300);
97
+ });
98
+ });
99
+ });
100
+ });
101
+ });
102
+ });
103
+
104
+ describe('Library Registration', () => {
105
+ it('should have 5 tools in ALL_TOOLS', () => {
106
+ expect(ALL_TOOLS.length).toBe(5);
107
+ });
108
+
109
+ it('should have all tools in astronomyCategory', () => {
110
+ expect(astronomyCategory.tools.length).toBe(5);
111
+ ALL_TOOLS.forEach(({ entry }) => {
112
+ const exists = astronomyCategory.tools.some((t: any) => t.id === entry.id);
113
+ expect(exists).toBe(true);
114
+ });
115
+ });
116
+ });
117
+
118
+ describe('Binary Consistency', () => {
119
+ ALL_TOOLS.forEach(({ entry }) => {
120
+ it(`${entry.id} files should exist in the tool folder`, () => {
121
+ const toolVarName = (entry.id ?? '').replace(/-([a-z])/g, (_: string, g: string) => g.toUpperCase());
122
+ const toolDir = path.join('src', 'tool', toolVarName);
123
+
124
+ expect(fs.existsSync(path.join(toolDir, 'component.astro'))).toBe(true);
125
+ expect(fs.existsSync(path.join(toolDir, 'seo.astro'))).toBe(true);
126
+ expect(fs.existsSync(path.join(toolDir, 'bibliography.astro'))).toBe(true);
127
+ expect(fs.existsSync(path.join(toolDir, 'index.ts'))).toBe(true);
128
+ });
129
+ });
130
+ });
131
+
132
+ describe('Bibliography Components Structure', () => {
133
+ ALL_TOOLS.forEach(({ entry }) => {
134
+ it(`${entry.id} should have a standard bibliography structure`, () => {
135
+ const toolVarName = (entry.id ?? '').replace(/-([a-z])/g, (_: string, g: string) => g.toUpperCase());
136
+ const componentPath = path.join('src', 'tool', toolVarName, 'bibliography.astro');
137
+ const content = fs.readFileSync(componentPath, 'utf-8');
138
+
139
+ expect(content).toContain("import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared'");
140
+ expect(content).toContain(`import { ${toolVarName} } from './index'`);
141
+ expect(content).toContain('<SharedBibliography links={content.bibliography} />');
142
+ });
143
+ });
144
+ });
145
+ });
146
+
@@ -1,14 +1,14 @@
1
- ---
2
- import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
- import { bortleVisualizer } from './index';
4
- import type { KnownLocale } from '../../types';
5
-
6
- interface Props {
7
- locale?: KnownLocale;
8
- }
9
-
10
- const { locale = 'es' } = Astro.props;
11
- const content = await bortleVisualizer.i18n[locale]?.();
12
- ---
13
-
14
- {content && <SharedBibliography links={content.bibliography} />}
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bortleVisualizer } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'es' } = Astro.props;
11
+ const content = await bortleVisualizer.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -1,8 +1,8 @@
1
- import type { BibliographyEntry } from '../../types';
2
-
3
- export const bibliography: BibliographyEntry[] = [
4
- { name: 'Gauging Light Pollution: The Bortle Dark-Sky Scale', url: 'https://skyandtelescope.org/astronomy-resources/light-pollution-and-astronomy-the-bortle-dark-sky-scale/' },
5
- { name: 'Light pollution map', url: 'https://www.lightpollutionmap.info/' },
6
- { name: 'International Dark-Sky Association', url: 'https://www.darksky.org/' },
7
- { name: 'Globe at Night', url: 'https://www.globeatnight.org/' },
8
- ];
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ { name: 'Gauging Light Pollution: The Bortle Dark-Sky Scale', url: 'https://skyandtelescope.org/astronomy-resources/light-pollution-and-astronomy-the-bortle-dark-sky-scale/' },
5
+ { name: 'Light pollution map', url: 'https://www.lightpollutionmap.info/' },
6
+ { name: 'International Dark-Sky Association', url: 'https://www.darksky.org/' },
7
+ { name: 'Globe at Night', url: 'https://www.globeatnight.org/' },
8
+ ];