You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
390 lines
12 KiB
390 lines
12 KiB
|
8 months ago
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
|
8 months ago
|
import { createI18n } from '../index.ts';
|
||
|
|
import type { TranslationNode, I18nString } from '../index.ts';
|
||
|
8 months ago
|
|
||
|
8 months ago
|
|
||
|
8 months ago
|
// ==============================
|
||
|
|
// SCHEMA DE TEST
|
||
|
|
// ==============================
|
||
|
|
|
||
|
|
const testSchema = {
|
||
|
|
common: {
|
||
|
|
ok: {
|
||
|
|
es: 'Aceptar',
|
||
|
|
en: 'OK',
|
||
|
|
fr: 'Accepter'
|
||
|
|
},
|
||
|
|
cancel: {
|
||
|
|
es: 'Cancelar',
|
||
|
|
en: 'Cancel'
|
||
|
|
}
|
||
|
|
},
|
||
|
|
checkout: {
|
||
|
|
pay: {
|
||
|
|
es: 'Pagar',
|
||
|
|
en: 'Pay'
|
||
|
|
},
|
||
|
|
total: (params: { amount: number; currency: string }) => ({
|
||
|
|
es: `Total: ${params.amount}${params.currency}`,
|
||
|
|
en: `Total: ${params.currency}${params.amount}`
|
||
|
|
})
|
||
|
|
},
|
||
|
|
errors: {
|
||
|
|
generic: (params: { code: number }) => ({
|
||
|
|
es: `Error ${params.code}`,
|
||
|
|
en: `Error ${params.code}`
|
||
|
|
})
|
||
|
|
},
|
||
|
|
// Clave solo en español (para probar fallback)
|
||
|
|
onlySpanish: {
|
||
|
|
es: 'Solo en español'
|
||
|
|
}
|
||
|
|
} satisfies TranslationNode;
|
||
|
|
|
||
|
|
// ==============================
|
||
|
|
// TESTS
|
||
|
|
// ==============================
|
||
|
|
|
||
|
|
describe('createI18n', () => {
|
||
|
|
|
||
|
|
describe('instanciación', () => {
|
||
|
|
it('crea una instancia con el locale por defecto', () => {
|
||
|
|
const i18n = createI18n(testSchema, 'es');
|
||
|
|
expect(i18n.getLocale()).toBe('es');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('crea instancias independientes con distintos locales', () => {
|
||
|
|
const i18nEs = createI18n(testSchema, 'es');
|
||
|
|
const i18nEn = createI18n(testSchema, 'en');
|
||
|
|
|
||
|
|
expect(i18nEs.getLocale()).toBe('es');
|
||
|
|
expect(i18nEn.getLocale()).toBe('en');
|
||
|
|
|
||
|
|
// Cambiar uno no afecta al otro
|
||
|
|
i18nEs.setLocale('fr');
|
||
|
|
expect(i18nEn.getLocale()).toBe('en');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('t() — traducciones simples', () => {
|
||
|
|
let i18n: ReturnType<typeof createI18n<typeof testSchema>>;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
i18n = createI18n(testSchema, 'es');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('devuelve la traducción en el locale actual', () => {
|
||
|
|
expect(i18n.t('common.ok')).toBe('Aceptar');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('devuelve la traducción tras cambiar de locale', () => {
|
||
|
|
i18n.setLocale('en');
|
||
|
|
expect(i18n.t('common.ok')).toBe('OK');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('devuelve la traducción en francés', () => {
|
||
|
|
i18n.setLocale('fr');
|
||
|
|
expect(i18n.t('common.ok')).toBe('Accepter');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('t() — traducciones con parámetros', () => {
|
||
|
|
let i18n: ReturnType<typeof createI18n<typeof testSchema>>;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
i18n = createI18n(testSchema, 'es');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('interpola parámetros correctamente en español', () => {
|
||
|
|
expect(i18n.t('checkout.total', { amount: 99, currency: '€' }))
|
||
|
|
.toBe('Total: 99€');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('interpola parámetros correctamente en inglés', () => {
|
||
|
|
i18n.setLocale('en');
|
||
|
|
expect(i18n.t('checkout.total', { amount: 99, currency: '$' }))
|
||
|
|
.toBe('Total: $99');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('interpola parámetros numéricos', () => {
|
||
|
|
expect(i18n.t('errors.generic', { code: 404 }))
|
||
|
|
.toBe('Error 404');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('t() — fallback', () => {
|
||
|
|
let i18n: ReturnType<typeof createI18n<typeof testSchema>>;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
i18n = createI18n(testSchema, 'es');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('hace fallback al locale por defecto si falta la traducción', () => {
|
||
|
|
i18n.setLocale('de'); // 'de' no existe en onlySpanish
|
||
|
|
expect(i18n.t('onlySpanish')).toBe('Solo en español');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('devuelve la clave si no existe en ningún locale', () => {
|
||
|
|
// @ts-expect-error — clave inexistente a propósito
|
||
|
|
expect(i18n.t('this.key.does.not.exist')).toBe('this.key.does.not.exist');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('loguea un warning en desarrollo cuando hace fallback', () => {
|
||
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||
|
|
const originalEnv = process.env.NODE_ENV;
|
||
|
|
process.env.NODE_ENV = 'development';
|
||
|
|
|
||
|
|
i18n.setLocale('de');
|
||
|
|
i18n.t('onlySpanish');
|
||
|
|
|
||
|
|
expect(warn).toHaveBeenCalledWith(
|
||
|
|
expect.stringContaining('Missing translation')
|
||
|
|
);
|
||
|
|
|
||
|
|
process.env.NODE_ENV = originalEnv;
|
||
|
|
warn.mockRestore();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('no loguea en producción', () => {
|
||
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||
|
|
const originalEnv = process.env.NODE_ENV;
|
||
|
|
process.env.NODE_ENV = 'production';
|
||
|
|
|
||
|
|
i18n.setLocale('de');
|
||
|
|
i18n.t('onlySpanish');
|
||
|
|
|
||
|
|
expect(warn).not.toHaveBeenCalled();
|
||
|
|
|
||
|
|
process.env.NODE_ENV = originalEnv;
|
||
|
|
warn.mockRestore();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('tForLocale()', () => {
|
||
|
|
it('resuelve en la locale indicada sin cambiar el estado global', () => {
|
||
|
|
const i18n = createI18n(testSchema, 'es');
|
||
|
|
|
||
|
|
const result = i18n.tForLocale('common.ok', 'en');
|
||
|
|
|
||
|
|
expect(result).toBe('OK');
|
||
|
|
expect(i18n.getLocale()).toBe('es'); // no ha cambiado
|
||
|
|
});
|
||
|
|
|
||
|
|
it('funciona con parámetros', () => {
|
||
|
|
const i18n = createI18n(testSchema, 'es');
|
||
|
|
|
||
|
|
const result = i18n.tForLocale('checkout.total', 'en', { amount: 50, currency: '$' });
|
||
|
|
|
||
|
|
expect(result).toBe('Total: $50');
|
||
|
|
expect(i18n.getLocale()).toBe('es');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('setLocale() / getLocale()', () => {
|
||
|
|
it('actualiza el locale correctamente', () => {
|
||
|
|
const i18n = createI18n(testSchema, 'es');
|
||
|
|
i18n.setLocale('en');
|
||
|
|
expect(i18n.getLocale()).toBe('en');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('onLocaleChange()', () => {
|
||
|
|
it('notifica al cambiar de locale', () => {
|
||
|
|
const i18n = createI18n(testSchema, 'es');
|
||
|
|
const listener = vi.fn();
|
||
|
|
|
||
|
|
i18n.onLocaleChange(listener);
|
||
|
|
i18n.setLocale('en');
|
||
|
|
|
||
|
|
expect(listener).toHaveBeenCalledWith('en');
|
||
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('no notifica después de hacer unsubscribe', () => {
|
||
|
|
const i18n = createI18n(testSchema, 'es');
|
||
|
|
const listener = vi.fn();
|
||
|
|
|
||
|
|
const unsubscribe = i18n.onLocaleChange(listener);
|
||
|
|
unsubscribe();
|
||
|
|
i18n.setLocale('en');
|
||
|
|
|
||
|
|
expect(listener).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('soporta múltiples listeners', () => {
|
||
|
|
const i18n = createI18n(testSchema, 'es');
|
||
|
|
const l1 = vi.fn();
|
||
|
|
const l2 = vi.fn();
|
||
|
|
|
||
|
|
i18n.onLocaleChange(l1);
|
||
|
|
i18n.onLocaleChange(l2);
|
||
|
|
i18n.setLocale('fr');
|
||
|
|
|
||
|
|
expect(l1).toHaveBeenCalledWith('fr');
|
||
|
|
expect(l2).toHaveBeenCalledWith('fr');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('tForLocale no dispara los listeners', () => {
|
||
|
|
const i18n = createI18n(testSchema, 'es');
|
||
|
|
const listener = vi.fn();
|
||
|
|
|
||
|
|
i18n.onLocaleChange(listener);
|
||
|
|
i18n.tForLocale('common.ok', 'en');
|
||
|
|
|
||
|
|
expect(listener).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
|
||
|
|
// ==============================
|
||
|
8 months ago
|
// TESTS ts()
|
||
|
8 months ago
|
// ==============================
|
||
|
|
|
||
|
|
describe('resolve()', () => {
|
||
|
|
let i18n: ReturnType<typeof createI18n<typeof testSchema>>;
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
i18n = createI18n(testSchema, 'es');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('devuelve el string tal cual si es un string simple', () => {
|
||
|
8 months ago
|
expect(i18n.ts('texto fijo')).toBe('texto fijo');
|
||
|
8 months ago
|
});
|
||
|
|
|
||
|
|
it('devuelve el string vacío sin errores', () => {
|
||
|
8 months ago
|
expect(i18n.ts('')).toBe('');
|
||
|
8 months ago
|
});
|
||
|
|
|
||
|
|
it('resuelve un LocaleRecord con el locale actual', () => {
|
||
|
|
const desc: I18nString = { es: 'una descripción', en: 'a description' };
|
||
|
8 months ago
|
expect(i18n.ts(desc)).toBe('una descripción');
|
||
|
8 months ago
|
});
|
||
|
|
|
||
|
|
it('resuelve un LocaleRecord tras cambiar de locale', () => {
|
||
|
|
const desc: I18nString = { es: 'una descripción', en: 'a description' };
|
||
|
|
i18n.setLocale('en');
|
||
|
8 months ago
|
expect(i18n.ts(desc)).toBe('a description');
|
||
|
8 months ago
|
});
|
||
|
|
|
||
|
|
it('hace fallback al defaultLocale si el locale actual no existe en el record', () => {
|
||
|
|
const desc: I18nString = { es: 'solo español' };
|
||
|
|
i18n.setLocale('de');
|
||
|
8 months ago
|
expect(i18n.ts(desc)).toBe('solo español');
|
||
|
8 months ago
|
});
|
||
|
|
|
||
|
|
it('funciona con un interface que usa I18nString', () => {
|
||
|
|
interface Producto {
|
||
|
|
id: string;
|
||
|
|
descripcion: I18nString;
|
||
|
|
}
|
||
|
|
|
||
|
|
const producto: Producto = {
|
||
|
|
id: '1',
|
||
|
|
descripcion: { es: 'Silla de madera', en: 'Wooden chair' }
|
||
|
|
};
|
||
|
|
|
||
|
8 months ago
|
expect(i18n.ts(producto.descripcion)).toBe('Silla de madera');
|
||
|
8 months ago
|
|
||
|
|
i18n.setLocale('en');
|
||
|
8 months ago
|
expect(i18n.ts(producto.descripcion)).toBe('Wooden chair');
|
||
|
8 months ago
|
});
|
||
|
|
|
||
|
|
it('funciona mezclando strings simples y LocaleRecord en el mismo array', () => {
|
||
|
|
const items: I18nString[] = [
|
||
|
|
'id-invariante',
|
||
|
|
{ es: 'nombre', en: 'name' },
|
||
|
|
'otro-invariante'
|
||
|
|
];
|
||
|
|
|
||
|
8 months ago
|
const resolved = items.map(i18n.ts);
|
||
|
8 months ago
|
expect(resolved).toEqual(['id-invariante', 'nombre', 'otro-invariante']);
|
||
|
|
});
|
||
|
8 months ago
|
});
|
||
|
|
|
||
|
|
|
||
|
8 months ago
|
// ==============================
|
||
|
|
// TESTS register()
|
||
|
|
// ==============================
|
||
|
|
|
||
|
|
describe('register()', () => {
|
||
|
|
|
||
|
|
const baseSchema = {
|
||
|
|
common: {
|
||
|
|
ok: { es: 'Aceptar', en: 'OK' }
|
||
|
|
}
|
||
|
|
} satisfies TranslationNode;
|
||
|
|
|
||
|
|
it('registra traducciones de un módulo bajo un namespace', () => {
|
||
|
|
const i18n = createI18n(baseSchema, 'es');
|
||
|
|
|
||
|
|
const extended = i18n.register('auth', {
|
||
|
|
loginFailed: { es: 'Login fallido', en: 'Login failed' }
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(extended.t('auth.loginFailed')).toBe('Login fallido');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('mantiene acceso a las claves del schema base', () => {
|
||
|
|
const i18n = createI18n(baseSchema, 'es');
|
||
|
|
|
||
|
|
const extended = i18n.register('auth', {
|
||
|
|
loginFailed: { es: 'Login fallido', en: 'Login failed' }
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(extended.t('common.ok')).toBe('Aceptar');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('hereda el locale actual en el momento del registro', () => {
|
||
|
|
const i18n = createI18n(baseSchema, 'es');
|
||
|
|
i18n.setLocale('en');
|
||
|
|
|
||
|
|
const extended = i18n.register('auth', {
|
||
|
|
loginFailed: { es: 'Login fallido', en: 'Login failed' }
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(extended.t('auth.loginFailed')).toBe('Login failed');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('se sincroniza cuando cambia el locale en la instancia base', () => {
|
||
|
|
const i18n = createI18n(baseSchema, 'es');
|
||
|
|
const extended = i18n.register('auth', {
|
||
|
|
loginFailed: { es: 'Login fallido', en: 'Login failed' }
|
||
|
|
});
|
||
|
|
|
||
|
|
i18n.setLocale('en');
|
||
|
|
|
||
|
|
expect(extended.t('auth.loginFailed')).toBe('Login failed');
|
||
|
|
expect(extended.t('common.ok')).toBe('OK');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('múltiples módulos se acumulan correctamente', () => {
|
||
|
|
const i18n = createI18n(baseSchema, 'es');
|
||
|
|
const withAuth = i18n.register('auth', {
|
||
|
|
loginFailed: { es: 'Login fallido', en: 'Login failed' }
|
||
|
|
});
|
||
|
|
const withAll = withAuth.register('checkout', {
|
||
|
|
pay: { es: 'Pagar', en: 'Pay' }
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(withAll.t('common.ok')).toBe('Aceptar');
|
||
|
|
expect(withAll.t('auth.loginFailed')).toBe('Login fallido');
|
||
|
|
expect(withAll.t('checkout.pay')).toBe('Pagar');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('cambiar locale en base se propaga a todos los módulos registrados', () => {
|
||
|
|
const i18n = createI18n(baseSchema, 'es');
|
||
|
|
const withAuth = i18n.register('auth', {
|
||
|
|
loginFailed: { es: 'Login fallido', en: 'Login failed' }
|
||
|
|
});
|
||
|
|
const withAll = withAuth.register('checkout', {
|
||
|
|
pay: { es: 'Pagar', en: 'Pay' }
|
||
|
|
});
|
||
|
|
|
||
|
|
i18n.setLocale('en');
|
||
|
|
|
||
|
|
expect(withAll.t('common.ok')).toBe('OK');
|
||
|
|
expect(withAll.t('auth.loginFailed')).toBe('Login failed');
|
||
|
|
expect(withAll.t('checkout.pay')).toBe('Pay');
|
||
|
|
});
|
||
|
|
});
|