import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createI18n } from '../index.ts'; import type { TranslationNode, I18nString } from '../index.ts'; // ============================== // 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>; 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>; 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>; 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(); }); }); }); // ============================== // TESTS ts() // ============================== describe('resolve()', () => { let i18n: ReturnType>; beforeEach(() => { i18n = createI18n(testSchema, 'es'); }); it('devuelve el string tal cual si es un string simple', () => { expect(i18n.ts('texto fijo')).toBe('texto fijo'); }); it('devuelve el string vacío sin errores', () => { expect(i18n.ts('')).toBe(''); }); it('resuelve un LocaleRecord con el locale actual', () => { const desc: I18nString = { es: 'una descripción', en: 'a description' }; expect(i18n.ts(desc)).toBe('una descripción'); }); it('resuelve un LocaleRecord tras cambiar de locale', () => { const desc: I18nString = { es: 'una descripción', en: 'a description' }; i18n.setLocale('en'); expect(i18n.ts(desc)).toBe('a description'); }); it('hace fallback al defaultLocale si el locale actual no existe en el record', () => { const desc: I18nString = { es: 'solo español' }; i18n.setLocale('de'); expect(i18n.ts(desc)).toBe('solo español'); }); 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' } }; expect(i18n.ts(producto.descripcion)).toBe('Silla de madera'); i18n.setLocale('en'); expect(i18n.ts(producto.descripcion)).toBe('Wooden chair'); }); it('funciona mezclando strings simples y LocaleRecord en el mismo array', () => { const items: I18nString[] = [ 'id-invariante', { es: 'nombre', en: 'name' }, 'otro-invariante' ]; const resolved = items.map(i18n.ts); expect(resolved).toEqual(['id-invariante', 'nombre', 'otro-invariante']); }); }); // ============================== // 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'); }); });