@jjlmoya/utils-pets 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 (103) hide show
  1. package/package.json +2 -2
  2. package/src/category/i18n/de.ts +1 -1
  3. package/src/category/i18n/en.ts +2 -2
  4. package/src/category/i18n/es.ts +1 -1
  5. package/src/category/i18n/fr.ts +1 -1
  6. package/src/category/i18n/pt.ts +1 -1
  7. package/src/category/i18n/tr.ts +1 -1
  8. package/src/category/index.ts +26 -26
  9. package/src/entries.ts +12 -12
  10. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  11. package/src/tests/category_seo_quality.test.ts +74 -0
  12. package/src/tests/faq_count.test.ts +22 -22
  13. package/src/tests/locale_completeness.test.ts +30 -30
  14. package/src/tests/no_h1_in_components.test.ts +1 -1
  15. package/src/tests/pet_logic.test.ts +49 -0
  16. package/src/tests/seo_length.test.ts +54 -54
  17. package/src/tests/seo_parity.test.ts +2 -2
  18. package/src/tests/seo_translation_completeness.test.ts +18 -12
  19. package/src/tests/seo_wellformed_export.test.ts +65 -0
  20. package/src/tests/spanish_leakage.test.ts +175 -175
  21. package/src/tests/tool_validation.test.ts +137 -137
  22. package/src/tool/petAge/component.astro +3 -3
  23. package/src/tool/petAge/i18n/de.ts +184 -184
  24. package/src/tool/petAge/i18n/en.ts +184 -184
  25. package/src/tool/petAge/i18n/es.ts +184 -184
  26. package/src/tool/petAge/i18n/fr.ts +184 -184
  27. package/src/tool/petAge/i18n/id.ts +184 -184
  28. package/src/tool/petAge/i18n/it.ts +184 -184
  29. package/src/tool/petAge/i18n/ja.ts +184 -184
  30. package/src/tool/petAge/i18n/ko.ts +184 -184
  31. package/src/tool/petAge/i18n/nl.ts +184 -184
  32. package/src/tool/petAge/i18n/pl.ts +184 -184
  33. package/src/tool/petAge/i18n/pt.ts +184 -184
  34. package/src/tool/petAge/i18n/ru.ts +184 -184
  35. package/src/tool/petAge/i18n/sv.ts +184 -184
  36. package/src/tool/petAge/i18n/tr.ts +185 -185
  37. package/src/tool/petAge/i18n/zh.ts +184 -184
  38. package/src/tool/petAge/seo.astro +15 -15
  39. package/src/tool/petGestation/i18n/de.ts +0 -2
  40. package/src/tool/petGestation/i18n/en.ts +0 -2
  41. package/src/tool/petGestation/i18n/es.ts +0 -2
  42. package/src/tool/petGestation/i18n/fr.ts +0 -2
  43. package/src/tool/petGestation/i18n/id.ts +0 -2
  44. package/src/tool/petGestation/i18n/it.ts +0 -2
  45. package/src/tool/petGestation/i18n/ja.ts +0 -2
  46. package/src/tool/petGestation/i18n/ko.ts +0 -2
  47. package/src/tool/petGestation/i18n/nl.ts +0 -2
  48. package/src/tool/petGestation/i18n/pl.ts +0 -2
  49. package/src/tool/petGestation/i18n/pt.ts +0 -2
  50. package/src/tool/petGestation/i18n/ru.ts +0 -2
  51. package/src/tool/petGestation/i18n/sv.ts +0 -2
  52. package/src/tool/petGestation/i18n/tr.ts +0 -2
  53. package/src/tool/petGestation/i18n/zh.ts +0 -2
  54. package/src/tool/petGestation/ui.ts +0 -2
  55. package/src/tool/petRation/i18n/de.ts +181 -181
  56. package/src/tool/petRation/i18n/en.ts +181 -181
  57. package/src/tool/petRation/i18n/es.ts +181 -181
  58. package/src/tool/petRation/i18n/fr.ts +181 -181
  59. package/src/tool/petRation/i18n/id.ts +181 -181
  60. package/src/tool/petRation/i18n/it.ts +181 -181
  61. package/src/tool/petRation/i18n/ja.ts +181 -181
  62. package/src/tool/petRation/i18n/ko.ts +181 -181
  63. package/src/tool/petRation/i18n/nl.ts +181 -181
  64. package/src/tool/petRation/i18n/pl.ts +181 -181
  65. package/src/tool/petRation/i18n/pt.ts +181 -181
  66. package/src/tool/petRation/i18n/ru.ts +181 -181
  67. package/src/tool/petRation/i18n/sv.ts +181 -181
  68. package/src/tool/petRation/i18n/tr.ts +181 -181
  69. package/src/tool/petRation/i18n/zh.ts +181 -181
  70. package/src/tool/petRation/seo.astro +15 -15
  71. package/src/tool/petToxicity/i18n/de.ts +0 -2
  72. package/src/tool/petToxicity/i18n/en.ts +0 -2
  73. package/src/tool/petToxicity/i18n/es.ts +2 -4
  74. package/src/tool/petToxicity/i18n/fr.ts +0 -2
  75. package/src/tool/petToxicity/i18n/id.ts +0 -2
  76. package/src/tool/petToxicity/i18n/it.ts +0 -2
  77. package/src/tool/petToxicity/i18n/ja.ts +0 -2
  78. package/src/tool/petToxicity/i18n/ko.ts +0 -2
  79. package/src/tool/petToxicity/i18n/nl.ts +0 -2
  80. package/src/tool/petToxicity/i18n/pl.ts +0 -2
  81. package/src/tool/petToxicity/i18n/pt.ts +0 -2
  82. package/src/tool/petToxicity/i18n/ru.ts +0 -2
  83. package/src/tool/petToxicity/i18n/sv.ts +0 -2
  84. package/src/tool/petToxicity/i18n/tr.ts +0 -2
  85. package/src/tool/petToxicity/i18n/zh.ts +0 -2
  86. package/src/tool/petToxicity/ui.ts +0 -2
  87. package/src/tool/petWaterIntake/i18n/de.ts +0 -15
  88. package/src/tool/petWaterIntake/i18n/en.ts +0 -15
  89. package/src/tool/petWaterIntake/i18n/es.ts +2 -17
  90. package/src/tool/petWaterIntake/i18n/fr.ts +0 -15
  91. package/src/tool/petWaterIntake/i18n/id.ts +0 -15
  92. package/src/tool/petWaterIntake/i18n/it.ts +0 -15
  93. package/src/tool/petWaterIntake/i18n/ja.ts +0 -15
  94. package/src/tool/petWaterIntake/i18n/ko.ts +0 -15
  95. package/src/tool/petWaterIntake/i18n/nl.ts +0 -15
  96. package/src/tool/petWaterIntake/i18n/pl.ts +0 -15
  97. package/src/tool/petWaterIntake/i18n/pt.ts +0 -15
  98. package/src/tool/petWaterIntake/i18n/ru.ts +0 -15
  99. package/src/tool/petWaterIntake/i18n/sv.ts +0 -15
  100. package/src/tool/petWaterIntake/i18n/tr.ts +0 -15
  101. package/src/tool/petWaterIntake/i18n/zh.ts +0 -15
  102. package/src/tool/petWaterIntake/ui.ts +0 -15
  103. package/src/tools.ts +19 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-pets",
3
- "version": "1.22.0",
3
+ "version": "1.24.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -27,7 +27,7 @@
27
27
  "lint": "eslint src/ --max-warnings 0 && stylelint \"src/**/*.{css,astro}\"",
28
28
  "check": "astro check",
29
29
  "type-check": "astro check",
30
- "test": "vitest run",
30
+ "test": "vitest run --reporter=verbose --testTimeout=30000",
31
31
  "preversion": "npm run lint && npm run test && npm run build",
32
32
  "postversion": "git push && git push --tags",
33
33
  "patch": "npm version patch",
@@ -3,7 +3,7 @@ import type { CategoryLocaleContent } from '../../types';
3
3
  export const content: CategoryLocaleContent = {
4
4
  slug: 'haustiere',
5
5
  title: 'Haustier Tools',
6
- description: 'Kostenlose Rechner und Dienstprogramme für Haustierbesitzer. Finden Sie das wahre Alter Ihres Hundes oder Ihrer Katze heraus und berechnen Sie die tägliche Futterration mit tierärztlicher Präzision.',
6
+ description: 'Kostenlose Rechner für Haustierbesitzer: Hunde- und Katzenalter sowie tägliche Futterrationen einfach einschätzen.',
7
7
  seo: [
8
8
  { type: 'title', text: 'Tierernährung und Wohlbefinden: Wissenschaft im Dienste Ihres Haustieres', level: 2 },
9
9
  {
@@ -2,13 +2,13 @@ import type { CategoryLocaleContent } from '../../types';
2
2
 
3
3
  export const content: CategoryLocaleContent = {
4
4
  slug: 'pets',
5
- title: 'Pet Tools',
5
+ title: 'Pet Care Calculators',
6
6
  description: 'Free calculators and utilities for pet owners. Find out your dog or cat\'s real age and calculate their daily ration with veterinary precision.',
7
7
  seo: [
8
8
  { type: 'title', text: 'Animal Nutrition and Wellbeing: Science in the Service of Your Pet', level: 2 },
9
9
  {
10
10
  type: 'paragraph',
11
- html: 'Caring for a dog, cat, or rabbit is much more than giving them food and affection. It involves understanding their biology, their aging rhythm, and their specific energy needs. In this section we offer <strong>free veterinary calculators</strong> that apply validated scientific formulas to help you make better decisions about your companion\'s health.',
11
+ html: 'Caring for a dog, cat, or rabbit involves understanding biology, aging and energy needs. Our <strong>free veterinary calculators</strong> use published formulas to support informed decisions about your companion\'s health.',
12
12
  },
13
13
  {
14
14
  type: 'paragraph',
@@ -8,7 +8,7 @@ export const content: CategoryLocaleContent = {
8
8
  { type: 'title', text: 'Nutrición y Bienestar Animal: Ciencia al Servicio de tu Mascota', level: 2 },
9
9
  {
10
10
  type: 'paragraph',
11
- html: 'Cuidar a un perro, un gato o un conejo es mucho más que darles comida y cariño. Implica entender su biología, su ritmo de envejecimiento y sus necesidades energéticas concretas. En esta sección ofrecemos <strong>calculadoras veterinarias gratuitas</strong> que aplican fórmulas científicas validadas para ayudarte a tomar mejores decisiones sobre la salud de tu compañero.',
11
+ html: 'Cuidar de un perro, gato o conejo implica entender su biología, envejecimiento y necesidades energéticas. Nuestras <strong>calculadoras veterinarias gratuitas</strong> usan fórmulas publicadas para orientar decisiones sobre su salud.',
12
12
  },
13
13
  {
14
14
  type: 'paragraph',
@@ -8,7 +8,7 @@ export const content: CategoryLocaleContent = {
8
8
  { type: 'title', text: 'Nutrition et Bien-être Animal: La Science au Service de votre Animal', level: 2 },
9
9
  {
10
10
  type: 'paragraph',
11
- html: 'Prendre soin d\'un chien, d\'un chat ou d\'un lapin va bien au-delà de leur donner de la nourriture et de l\'affection. Cela implique de comprendre leur biologie, leur rythme de vieillissement et leurs besoins énergétiques spécifiques. Dans cette section, nous proposons des <strong>calculatrices vétérinaires gratuites</strong> qui appliquent des formules scientifiques validées pour vous aider à prendre de meilleures décisions concernant la santé de votre compagnon.',
11
+ html: 'Prendre soin d\'un chien, d\'un chat ou d\'un lapin implique de comprendre sa biologie, son vieillissement et ses besoins énergétiques. Nos <strong>calculatrices vétérinaires gratuites</strong> utilisent des formules publiées pour éclairer vos décisions.',
12
12
  },
13
13
  {
14
14
  type: 'paragraph',
@@ -8,7 +8,7 @@ export const content: CategoryLocaleContent = {
8
8
  { type: 'title', text: 'Nutrição e Bem-estar Animal: Ciência ao Serviço do Seu Animal', level: 2 },
9
9
  {
10
10
  type: 'paragraph',
11
- html: 'Cuidar de um cão, gato ou coelho é muito mais do que dar-lhes comida e afeto. Envolve compreender a sua biologia, o seu ritmo de envelhecimento e as suas necessidades energéticas específicas. Nesta secção oferecemos <strong>calculadoras veterinárias gratuitas</strong> que aplicam fórmulas científicas validadas para o ajudar a tomar melhores decisões sobre a saúde do seu companheiro.',
11
+ html: 'Cuidar de um cão, gato ou coelho implica compreender a sua biologia, envelhecimento e necessidades energéticas. As nossas <strong>calculadoras veterinárias gratuitas</strong> usam fórmulas publicadas para apoiar decisões informadas.',
12
12
  },
13
13
  {
14
14
  type: 'paragraph',
@@ -3,7 +3,7 @@ import type { CategoryLocaleContent } from '../../types';
3
3
  export const content: CategoryLocaleContent = {
4
4
  slug: 'evcil-hayvanlar',
5
5
  title: 'Evcil Hayvan Araçları',
6
- description: 'Evcil hayvan sahipleri için ücretsiz hesaplayıcılar ve yardımcı programlar. Köpeğinizin veya kedinizin gerçek yaşını öğrenin ve günlük mama miktarını veteriner hassasiyetiyle hesaplayın.',
6
+ description: 'Evcil hayvan sahipleri için ücretsiz hesaplayıcılar: köpek ve kedi yaşı ile günlük mama miktarını tahmin edin.',
7
7
  seo: [
8
8
  { type: 'title', text: 'Hayvan Beslenmesi ve Refahı: Evcil Hayvanınızın Hizmetinde Bilim', level: 2 },
9
9
  {
@@ -1,29 +1,29 @@
1
- import type { PetCategoryEntry } from '../types';
2
- import { petAge } from '../tool/petAge/entry';
3
- import { petRation } from '../tool/petRation/entry';
4
- import { petGestation } from '../tool/petGestation/entry';
5
- import { petToxicity } from '../tool/petToxicity/entry';
1
+ import type { PetCategoryEntry } from '../types';
2
+ import { petAge } from '../tool/petAge/entry';
3
+ import { petRation } from '../tool/petRation/entry';
4
+ import { petGestation } from '../tool/petGestation/entry';
5
+ import { petToxicity } from '../tool/petToxicity/entry';
6
6
  import { petWaterIntake } from '../tool/petWaterIntake/entry';
7
7
  import { petCarrierCrateSizePlanner } from '../tool/petCarrierCrateSizePlanner/entry';
8
-
9
- export const petsCategory: PetCategoryEntry = {
10
- icon: 'mdi:paw',
8
+
9
+ export const petsCategory: PetCategoryEntry = {
10
+ icon: 'mdi:paw',
11
11
  tools: [petAge, petRation, petGestation, petToxicity, petWaterIntake, petCarrierCrateSizePlanner],
12
- i18n: {
13
- en: () => import('./i18n/en').then((m) => m.content),
14
- es: () => import('./i18n/es').then((m) => m.content),
15
- fr: () => import('./i18n/fr').then((m) => m.content),
16
- de: () => import('./i18n/de').then((m) => m.content),
17
- id: () => import('./i18n/id').then((m) => m.content),
18
- it: () => import('./i18n/it').then((m) => m.content),
19
- ja: () => import('./i18n/ja').then((m) => m.content),
20
- ko: () => import('./i18n/ko').then((m) => m.content),
21
- nl: () => import('./i18n/nl').then((m) => m.content),
22
- pl: () => import('./i18n/pl').then((m) => m.content),
23
- pt: () => import('./i18n/pt').then((m) => m.content),
24
- ru: () => import('./i18n/ru').then((m) => m.content),
25
- sv: () => import('./i18n/sv').then((m) => m.content),
26
- tr: () => import('./i18n/tr').then((m) => m.content),
27
- zh: () => import('./i18n/zh').then((m) => m.content),
28
- },
29
- };
12
+ i18n: {
13
+ en: () => import('./i18n/en').then((m) => m.content),
14
+ es: () => import('./i18n/es').then((m) => m.content),
15
+ fr: () => import('./i18n/fr').then((m) => m.content),
16
+ de: () => import('./i18n/de').then((m) => m.content),
17
+ id: () => import('./i18n/id').then((m) => m.content),
18
+ it: () => import('./i18n/it').then((m) => m.content),
19
+ ja: () => import('./i18n/ja').then((m) => m.content),
20
+ ko: () => import('./i18n/ko').then((m) => m.content),
21
+ nl: () => import('./i18n/nl').then((m) => m.content),
22
+ pl: () => import('./i18n/pl').then((m) => m.content),
23
+ pt: () => import('./i18n/pt').then((m) => m.content),
24
+ ru: () => import('./i18n/ru').then((m) => m.content),
25
+ sv: () => import('./i18n/sv').then((m) => m.content),
26
+ tr: () => import('./i18n/tr').then((m) => m.content),
27
+ zh: () => import('./i18n/zh').then((m) => m.content),
28
+ },
29
+ };
package/src/entries.ts CHANGED
@@ -1,18 +1,18 @@
1
- export { petAge } from './tool/petAge/entry';
2
- export type { PetAgeUI, PetAgeLocaleContent } from './tool/petAge/entry';
3
- export { petRation } from './tool/petRation/entry';
4
- export type { PetRationUI, PetRationLocaleContent } from './tool/petRation/entry';
5
- export { petToxicity } from './tool/petToxicity/entry';
6
- export type { PetToxicityUI, PetToxicityLocaleContent } from './tool/petToxicity/entry';
7
- export { petWaterIntake } from './tool/petWaterIntake/entry';
1
+ export { petAge } from './tool/petAge/entry';
2
+ export type { PetAgeUI, PetAgeLocaleContent } from './tool/petAge/entry';
3
+ export { petRation } from './tool/petRation/entry';
4
+ export type { PetRationUI, PetRationLocaleContent } from './tool/petRation/entry';
5
+ export { petToxicity } from './tool/petToxicity/entry';
6
+ export type { PetToxicityUI, PetToxicityLocaleContent } from './tool/petToxicity/entry';
7
+ export { petWaterIntake } from './tool/petWaterIntake/entry';
8
8
  export type { PetWaterIntakeUI, PetWaterIntakeLocaleContent } from './tool/petWaterIntake/entry';
9
9
  export { petCarrierCrateSizePlanner } from './tool/petCarrierCrateSizePlanner/entry';
10
10
  export type { PetCarrierCrateSizePlannerUI, PetCarrierCrateSizePlannerLocaleContent } from './tool/petCarrierCrateSizePlanner/entry';
11
- export { petsCategory } from './category';
12
- import { petAge } from './tool/petAge/entry';
13
- import { petRation } from './tool/petRation/entry';
14
- import { petGestation } from './tool/petGestation/entry';
15
- import { petToxicity } from './tool/petToxicity/entry';
11
+ export { petsCategory } from './category';
12
+ import { petAge } from './tool/petAge/entry';
13
+ import { petRation } from './tool/petRation/entry';
14
+ import { petGestation } from './tool/petGestation/entry';
15
+ import { petToxicity } from './tool/petToxicity/entry';
16
16
  import { petWaterIntake } from './tool/petWaterIntake/entry';
17
17
  import { petCarrierCrateSizePlanner } from './tool/petCarrierCrateSizePlanner/entry';
18
18
  export const ALL_ENTRIES = [petAge, petRation, petGestation, petToxicity, petWaterIntake, petCarrierCrateSizePlanner];
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readdirSync, readFileSync, existsSync } from 'fs';
3
+ import { join, relative } from 'path';
4
+
5
+ function findBibliographyAstroFiles(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(...findBibliographyAstroFiles(fullPath));
14
+ } else if (entry.isFile() && entry.name === 'bibliography.astro') {
15
+ files.push(fullPath);
16
+ }
17
+ }
18
+
19
+ return files;
20
+ }
21
+
22
+ const toolDir = join(process.cwd(), 'src', 'tool');
23
+ const bibliographyFiles = findBibliographyAstroFiles(toolDir);
24
+
25
+ describe('Bibliography Component Wellformed Export', () => {
26
+ it('found tool bibliography.astro components', () => {
27
+ expect(bibliographyFiles.length).toBeGreaterThan(0);
28
+ });
29
+
30
+ bibliographyFiles.forEach((file) => {
31
+ const relativePath = relative(process.cwd(), file);
32
+
33
+ it(`${relativePath} should use SharedBibliography from @jjlmoya/utils-shared`, () => {
34
+ const content = readFileSync(file, 'utf-8');
35
+
36
+ const usesSharedComponent =
37
+ content.includes('@jjlmoya/utils-shared') &&
38
+ (content.includes('Bibliography') || content.includes('SharedBibliography'));
39
+
40
+ expect(
41
+ usesSharedComponent,
42
+ `File "${relativePath}" does not use the standard Bibliography component from @jjlmoya/utils-shared, resulting in unstyled or raw bibliography output.`,
43
+ ).toBe(true);
44
+ });
45
+ });
46
+ });
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { CategoryLocaleContent } from '../types';
3
+
4
+ const EXPECTED_LOCALES = [
5
+ 'de', 'en', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh',
6
+ ] as const;
7
+ const COMPACT_LOCALES = new Set(['ja', 'ko', 'zh']);
8
+
9
+ const localeModules = import.meta.glob('../category/i18n/*.ts', { eager: true }) as Record<
10
+ string,
11
+ { content: CategoryLocaleContent }
12
+ >;
13
+
14
+ function textFrom(value: unknown): string {
15
+ if (typeof value === 'string') return value.replace(/<[^>]*>/g, ' ');
16
+ if (Array.isArray(value)) return value.map(textFrom).join(' ');
17
+ if (value && typeof value === 'object') return Object.values(value).map(textFrom).join(' ');
18
+ return '';
19
+ }
20
+
21
+ const STRONG_CLAIM = /\b(?:garantiz\w*|guarante\w*|validat(?:ed)|valid(?:ated|ado|ada|ados|adas|ée|ées)|actualizad\w*)\b/iu;
22
+
23
+ function getContents(): Map<string, CategoryLocaleContent> {
24
+ return new Map(
25
+ Object.entries(localeModules).map(([file, module]) => [file.match(/\/([a-z]{2})\.ts$/u)?.[1] ?? file, module.content]),
26
+ );
27
+ }
28
+
29
+ function validateContent(locale: string, content: CategoryLocaleContent): string[] {
30
+ const failures: string[] = [];
31
+ const check = (condition: boolean, message: string) => { if (!condition) failures.push(`${locale}: ${message}`); };
32
+ const minimumTitleLength = COMPACT_LOCALES.has(locale) ? 4 : 12;
33
+ const minimumDescriptionLength = COMPACT_LOCALES.has(locale) ? 20 : 60;
34
+ check(content.title.trim().length >= minimumTitleLength && content.title.trim().length <= 70, 'title must use a useful 4/12–70 character length');
35
+ check(content.description.trim().length >= minimumDescriptionLength && content.description.trim().length <= 180, 'description must use a useful 20/60–180 character length');
36
+ check(content.seo.length >= 2, 'SEO needs at least two sections');
37
+ check(content.seo.some((section) => section.type === 'paragraph'), 'SEO needs visible explanatory copy');
38
+
39
+ const seoText = textFrom(content.seo).replace(/\s+/g, ' ').trim();
40
+ const minimumSeoLength = COMPACT_LOCALES.has(locale) ? 120 : 240;
41
+ check(seoText.length >= minimumSeoLength, 'SEO copy must contain the minimum visible copy for its script');
42
+ check(content.seo.filter((section) => section.type === 'title').length >= 1, 'SEO needs a section heading');
43
+ check(!/[�]/u.test(seoText), 'SEO contains replacement characters');
44
+ check(!/ {2,}/u.test(seoText), 'SEO contains duplicated whitespace');
45
+ check(!STRONG_CLAIM.test(seoText), 'SEO contains an unsupported strong claim');
46
+
47
+ check(content.slug.trim().length > 0 && /^[a-z0-9-]+$/u.test(content.slug), 'slug must be a non-empty URL-safe value');
48
+ return failures;
49
+ }
50
+
51
+ describe('Category SEO quality contract', () => {
52
+ it('has one content file for every configured locale', () => {
53
+ const contents = getContents();
54
+ expect([...contents.keys()].sort()).toEqual([...EXPECTED_LOCALES].sort());
55
+ });
56
+
57
+ it('keeps metadata, SEO structure and copy quality above the minimum in every locale', () => {
58
+ const contents = getContents();
59
+ const english = contents.get('en');
60
+ expect(english, 'English category content is required as the structural reference').toBeDefined();
61
+
62
+ const failures = EXPECTED_LOCALES.flatMap((locale) => {
63
+ const content = contents.get(locale);
64
+ return content ? validateContent(locale, content) : [];
65
+ });
66
+
67
+ for (const locale of EXPECTED_LOCALES) {
68
+ const content = contents.get(locale);
69
+ if (!content) failures.push(`${locale}: category content is missing`);
70
+ }
71
+
72
+ expect(failures, 'category SEO quality failures').toEqual([]);
73
+ }, 30000);
74
+ });
@@ -1,23 +1,23 @@
1
- import { describe, it, expect } from 'vitest';
2
- import * as DATA from '../data';
3
-
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as DATA from '../data';
3
+
4
4
  const TOOLS = [DATA.petAge, DATA.petRation, DATA.petGestation, DATA.petToxicity];
5
-
6
- describe('FAQ Content Validation', () => {
7
- TOOLS.forEach((entry) => {
8
- describe(`Tool: ${entry.id}`, () => {
9
- Object.keys(entry.i18n).forEach((locale) => {
10
- it(`${locale}: should have at least 3 FAQ items`, async () => {
11
- const loader = (entry.i18n as any)[locale];
12
- const content = await loader();
13
-
14
- if (!content.faq) {
15
- throw new Error(`Tool ${entry.id} (${locale}) is missing the 'faq' property entirely.`);
16
- }
17
-
18
- expect(content.faq.length, `Tool ${entry.id} (${locale}) has only ${content.faq.length} FAQs, minimum 3 required.`).toBeGreaterThanOrEqual(3);
19
- });
20
- });
21
- });
22
- });
23
- });
5
+
6
+ describe('FAQ Content Validation', () => {
7
+ TOOLS.forEach((entry) => {
8
+ describe(`Tool: ${entry.id}`, () => {
9
+ Object.keys(entry.i18n).forEach((locale) => {
10
+ it(`${locale}: should have at least 3 FAQ items`, async () => {
11
+ const loader = (entry.i18n as any)[locale];
12
+ const content = await loader();
13
+
14
+ if (!content.faq) {
15
+ throw new Error(`Tool ${entry.id} (${locale}) is missing the 'faq' property entirely.`);
16
+ }
17
+
18
+ expect(content.faq.length, `Tool ${entry.id} (${locale}) has only ${content.faq.length} FAQs, minimum 3 required.`).toBeGreaterThanOrEqual(3);
19
+ });
20
+ });
21
+ });
22
+ });
23
+ });
@@ -1,31 +1,31 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { ALL_TOOLS } from '../tools';
3
- import type { ToolLocaleContent } from '../types';
4
-
5
- describe('Locale Completeness Validation', () => {
6
- ALL_TOOLS.forEach((tool) => {
7
- describe(`Tool: ${tool.entry.id}`, () => {
8
- Object.keys(tool.entry.i18n).forEach((locale) => {
9
- describe(`Locale: ${locale}`, () => {
10
- it('faq should be defined', async () => {
11
- const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
12
- const content = (await loader?.()) as ToolLocaleContent;
13
-
14
- expect(Array.isArray(content.faq)).toBe(true);
15
- });
16
-
17
- it('bibliography should be defined', async () => {
18
- const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
19
- const content = (await loader?.()) as ToolLocaleContent;
20
-
21
- expect(Array.isArray(content.bibliography)).toBe(true);
22
- });
23
- });
24
- });
25
- });
26
- });
27
-
28
- it('all tools registered', () => {
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+ import type { ToolLocaleContent } from '../types';
4
+
5
+ describe('Locale Completeness Validation', () => {
6
+ ALL_TOOLS.forEach((tool) => {
7
+ describe(`Tool: ${tool.entry.id}`, () => {
8
+ Object.keys(tool.entry.i18n).forEach((locale) => {
9
+ describe(`Locale: ${locale}`, () => {
10
+ it('faq should be defined', async () => {
11
+ const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
12
+ const content = (await loader?.()) as ToolLocaleContent;
13
+
14
+ expect(Array.isArray(content.faq)).toBe(true);
15
+ });
16
+
17
+ it('bibliography should be defined', async () => {
18
+ const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
19
+ const content = (await loader?.()) as ToolLocaleContent;
20
+
21
+ expect(Array.isArray(content.bibliography)).toBe(true);
22
+ });
23
+ });
24
+ });
25
+ });
26
+ });
27
+
28
+ it('all tools registered', () => {
29
29
  expect(ALL_TOOLS.length).toBe(6);
30
- });
31
- });
30
+ });
31
+ });
@@ -45,4 +45,4 @@ describe('No H1 in Components', () => {
45
45
  });
46
46
  });
47
47
  });
48
-
48
+
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ calculateCatHumanAge,
4
+ calculateDogHumanAge,
5
+ calculatePetHumanAge,
6
+ getCatStageKey,
7
+ getDogMilestone,
8
+ } from '../tool/petAge/logic';
9
+ import {
10
+ calculateDER,
11
+ calculateRation,
12
+ calculateRER,
13
+ getStatusKey,
14
+ } from '../tool/petRation/logic';
15
+
16
+ describe('pet age reference calculations', () => {
17
+ it('uses the documented first-year and second-year anchors', () => {
18
+ expect(calculateDogHumanAge(1, 'small')).toBe(15);
19
+ expect(calculateDogHumanAge(2, 'small')).toBe(24);
20
+ expect(calculateCatHumanAge(1)).toBe(15);
21
+ expect(calculateCatHumanAge(2)).toBe(24);
22
+ });
23
+
24
+ it('handles zero and size-specific adult progression', () => {
25
+ expect(calculatePetHumanAge('dog', 0, 'giant')).toBe(0);
26
+ expect(calculatePetHumanAge('cat', 0)).toBe(0);
27
+ expect(calculateDogHumanAge(3, 'small')).toBe(29);
28
+ expect(calculateDogHumanAge(3, 'giant')).toBe(32);
29
+ expect(getCatStageKey(80)).toBe('stageCatVenerable');
30
+ expect(getDogMilestone(8)).toBe('Chequeo Geriátrico Semestral');
31
+ });
32
+ });
33
+
34
+ describe('pet ration reference calculations', () => {
35
+ it('calculates RER and DER using the selected profile', () => {
36
+ expect(calculateRER(10)).toBeCloseTo(393.64, 1);
37
+ expect(calculateDER(400, 'dog', 'adult', 'low')).toBe(480);
38
+ expect(calculateDER(400, 'dog', 'adult', 'high')).toBe(800);
39
+ });
40
+
41
+ it('keeps dry and wet ration outputs non-negative and preserves the split', () => {
42
+ const ration = calculateRation(700, 0.4);
43
+ expect(ration.dryGrams).toBe(120);
44
+ expect(ration.wetGrams).toBe(280);
45
+ expect(calculateRation(0, 0)).toEqual({ dryGrams: 0, wetGrams: 0 });
46
+ expect(getStatusKey('dog', 'puppy', 5)).toBe('statusPuppy');
47
+ expect(getStatusKey('dog', 'adult', 50)).toBe('statusLargeDog');
48
+ });
49
+ });
@@ -1,54 +1,54 @@
1
- import { describe, it, expect } from 'vitest';
2
- import * as DATA from '../data';
3
-
4
- const ENTRIES = [
5
- { id: 'petAge', i18n: DATA.petAge.i18n },
6
- { id: 'petRation', i18n: DATA.petRation.i18n },
7
- { id: 'petsCategory', i18n: DATA.petsCategory.i18n },
8
- ];
9
-
10
- describe('SEO Content Length Validation', () => {
11
- ENTRIES.forEach((entry) => {
12
- describe(`Tool: ${entry.id}`, () => {
13
- Object.keys(entry.i18n).forEach((locale) => {
14
- it(`${locale}: SEO section should contain between 400 and 900 words`, async () => {
15
- const loader = (entry.i18n as any)[locale];
16
- const content = await loader();
17
- if (!content.seo) return;
18
-
19
- let combinedText = '';
20
- content.seo.forEach((section: any) => {
21
- if (section.text) combinedText += section.text + ' ';
22
- if (section.html) combinedText += section.html + ' ';
23
- if (section.title) combinedText += section.title + ' ';
24
- if (section.items) {
25
- section.items.forEach((item: any) => {
26
- if (typeof item === 'string') combinedText += item + ' ';
27
- else {
28
- if (item.label) combinedText += item.label + ' ';
29
- if (item.value) combinedText += item.value + ' ';
30
- if (item.term) combinedText += item.term + ' ';
31
- if (item.definition) combinedText += item.definition + ' ';
32
- if (item.pro) combinedText += item.pro + ' ';
33
- if (item.con) combinedText += item.con + ' ';
34
- }
35
- });
36
- }
37
- if (section.headers) combinedText += section.headers.join(' ') + ' ';
38
- if (section.rows) {
39
- section.rows.forEach((row: string[]) => {
40
- combinedText += row.join(' ') + ' ';
41
- });
42
- }
43
- });
44
-
45
- const cleanText = combinedText.replace(/<[^>]*>?/gm, ' ').trim();
46
- const words = cleanText.split(/\s+/).filter((w: string) => w.length > 0);
47
-
48
- expect(words.length, `Tool ${entry.id} (${locale}) has ${words.length} words. Target: 400-900.`).toBeGreaterThanOrEqual(400);
49
- expect(words.length, `Tool ${entry.id} (${locale}) has ${words.length} words. Target: 400-900.`).toBeLessThanOrEqual(900);
50
- });
51
- });
52
- });
53
- });
54
- });
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as DATA from '../data';
3
+
4
+ const ENTRIES = [
5
+ { id: 'petAge', i18n: DATA.petAge.i18n },
6
+ { id: 'petRation', i18n: DATA.petRation.i18n },
7
+ { id: 'petsCategory', i18n: DATA.petsCategory.i18n },
8
+ ];
9
+
10
+ describe('SEO Content Length Validation', () => {
11
+ ENTRIES.forEach((entry) => {
12
+ describe(`Tool: ${entry.id}`, () => {
13
+ Object.keys(entry.i18n).forEach((locale) => {
14
+ it(`${locale}: SEO section should contain between 400 and 900 words`, async () => {
15
+ const loader = (entry.i18n as any)[locale];
16
+ const content = await loader();
17
+ if (!content.seo) return;
18
+
19
+ let combinedText = '';
20
+ content.seo.forEach((section: any) => {
21
+ if (section.text) combinedText += section.text + ' ';
22
+ if (section.html) combinedText += section.html + ' ';
23
+ if (section.title) combinedText += section.title + ' ';
24
+ if (section.items) {
25
+ section.items.forEach((item: any) => {
26
+ if (typeof item === 'string') combinedText += item + ' ';
27
+ else {
28
+ if (item.label) combinedText += item.label + ' ';
29
+ if (item.value) combinedText += item.value + ' ';
30
+ if (item.term) combinedText += item.term + ' ';
31
+ if (item.definition) combinedText += item.definition + ' ';
32
+ if (item.pro) combinedText += item.pro + ' ';
33
+ if (item.con) combinedText += item.con + ' ';
34
+ }
35
+ });
36
+ }
37
+ if (section.headers) combinedText += section.headers.join(' ') + ' ';
38
+ if (section.rows) {
39
+ section.rows.forEach((row: string[]) => {
40
+ combinedText += row.join(' ') + ' ';
41
+ });
42
+ }
43
+ });
44
+
45
+ const cleanText = combinedText.replace(/<[^>]*>?/gm, ' ').trim();
46
+ const words = cleanText.split(/\s+/).filter((w: string) => w.length > 0);
47
+
48
+ expect(words.length, `Tool ${entry.id} (${locale}) has ${words.length} words. Target: 400-900.`).toBeGreaterThanOrEqual(400);
49
+ expect(words.length, `Tool ${entry.id} (${locale}) has ${words.length} words. Target: 400-900.`).toBeLessThanOrEqual(900);
50
+ });
51
+ });
52
+ });
53
+ });
54
+ });
@@ -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 = {