diff --git a/src/libs/i18n/i18n.engine.ts b/src/libs/i18n/i18n.engine.ts index c8c54d9..7a67afe 100644 --- a/src/libs/i18n/i18n.engine.ts +++ b/src/libs/i18n/i18n.engine.ts @@ -47,11 +47,7 @@ export function createI18n( return () => listeners.delete(fn); } - /** - * Resuelve un LocaleRecord con el locale actual, - * con fallback al defaultLocale. - */ - function resolveRecord(record: LocaleRecord, path?: string): string { + function tsRecord(record: LocaleRecord, path?: string): string { const translation = record[currentLocale]; if (translation === undefined) { @@ -67,17 +63,17 @@ export function createI18n( } /** - * ts traduce un I18nString con el locale actual. + * Translate String — resuelve un I18nString con el locale actual. * - Si es un string simple, lo devuelve tal cual. - * - Si es un LocaleRecord, lo resuelve con el locale actual con fallback. + * - Si es un LocaleRecord, lo resuelve con fallback al defaultLocale. * * @example - * ts('texto fijo') // → "texto fijo" - * ts({ es: 'descripción', en: 'description' }) // → "descripción" (locale 'es') + * ts('texto fijo') // → "texto fijo" + * ts({ es: 'descripción', en: 'a description' }) // → "descripción" (locale 'es') */ function ts(value: I18nString): string { if (typeof value === 'string') return value; - return resolveRecord(value); + return tsRecord(value); } function t< @@ -101,7 +97,7 @@ export function createI18n( ? (value as Function)(args[0]) : value; - return resolveRecord(record, path as string); + return tsRecord(record, path as string); } function tForLocale< @@ -119,5 +115,6 @@ export function createI18n( return result; } - return { t, tForLocale, resolve: ts, setLocale, getLocale, onLocaleChange }; -} \ No newline at end of file + return { t, tForLocale, ts, setLocale, getLocale, onLocaleChange }; +} + diff --git a/src/libs/i18n/i18n.factory.ts b/src/libs/i18n/i18n.instance.ts similarity index 95% rename from src/libs/i18n/i18n.factory.ts rename to src/libs/i18n/i18n.instance.ts index 8fee746..1ffa315 100644 --- a/src/libs/i18n/i18n.factory.ts +++ b/src/libs/i18n/i18n.instance.ts @@ -7,5 +7,7 @@ import { translations } from './translations'; export const i18n = createI18n(translations, 'es'); + + // Desestructura si prefieres usar t() directamente export const { t, tForLocale, setLocale, getLocale, onLocaleChange } = i18n; \ No newline at end of file diff --git a/src/libs/i18n/i18n.test.ts b/src/libs/i18n/i18n.test.ts index 749b88b..c8befd5 100644 --- a/src/libs/i18n/i18n.test.ts +++ b/src/libs/i18n/i18n.test.ts @@ -6,6 +6,8 @@ import type { TranslationNode, I18nString } from './index'; // SCHEMA DE TEST // ============================== + + const testSchema = { common: { ok: { @@ -248,28 +250,28 @@ describe('resolve()', () => { }); it('devuelve el string tal cual si es un string simple', () => { - expect(i18n.resolve('texto fijo')).toBe('texto fijo'); + expect(i18n.ts('texto fijo')).toBe('texto fijo'); }); it('devuelve el string vacío sin errores', () => { - expect(i18n.resolve('')).toBe(''); + expect(i18n.ts('')).toBe(''); }); it('resuelve un LocaleRecord con el locale actual', () => { const desc: I18nString = { es: 'una descripción', en: 'a description' }; - expect(i18n.resolve(desc)).toBe('una descripción'); + 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.resolve(desc)).toBe('a description'); + 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.resolve(desc)).toBe('solo español'); + expect(i18n.ts(desc)).toBe('solo español'); }); it('funciona con un interface que usa I18nString', () => { @@ -283,10 +285,10 @@ describe('resolve()', () => { descripcion: { es: 'Silla de madera', en: 'Wooden chair' } }; - expect(i18n.resolve(producto.descripcion)).toBe('Silla de madera'); + expect(i18n.ts(producto.descripcion)).toBe('Silla de madera'); i18n.setLocale('en'); - expect(i18n.resolve(producto.descripcion)).toBe('Wooden chair'); + expect(i18n.ts(producto.descripcion)).toBe('Wooden chair'); }); it('funciona mezclando strings simples y LocaleRecord en el mismo array', () => { @@ -296,7 +298,11 @@ describe('resolve()', () => { 'otro-invariante' ]; - const resolved = items.map(i18n.resolve); + const resolved = items.map(i18n.ts); expect(resolved).toEqual(['id-invariante', 'nombre', 'otro-invariante']); }); -}); \ No newline at end of file +}); + + +const i18n = createI18n(testSchema, 'es'); +console.log(Object.keys(i18n)); // ¿aparece 'ts'? \ No newline at end of file diff --git a/src/libs/i18n/index.ts b/src/libs/i18n/index.ts index 821f817..10c39a9 100644 --- a/src/libs/i18n/index.ts +++ b/src/libs/i18n/index.ts @@ -1,3 +1,4 @@ +import { createI18n } from './i18n.engine.ts'; export { createI18n } from './i18n.engine'; export { translations } from './translations'; @@ -21,5 +22,7 @@ export type { HasParams, } from './i18n.types'; +export type I18n = ReturnType; + export type { TranslationSchema } from './translations'; diff --git a/src/libs/logr/index.ts b/src/libs/logr/index.ts new file mode 100644 index 0000000..6741890 --- /dev/null +++ b/src/libs/logr/index.ts @@ -0,0 +1,14 @@ +import { createLogger} from "./logr.engine.ts"; + + + +export * from './logr.types.ts'; +export * from './logr.engine.ts'; + +// ============================================================================ +// TYPE HELPER +// ============================================================================ + + +export type Logger = ReturnType; + diff --git a/src/libs/logr/logr.engine.ts b/src/libs/logr/logr.engine.ts new file mode 100644 index 0000000..6162e24 --- /dev/null +++ b/src/libs/logr/logr.engine.ts @@ -0,0 +1,119 @@ +import type { I18n, I18nString } from '@/libs/i18n'; +import type { LoggerOptions, LogEntry, MessageCategory} from "./logr.types.ts"; +import { LogLevel } from "./logr.types"; + + + + +// ============================================================================ +// FACTORY +// ============================================================================ + +export function createLogger(i18n: I18n, options: LoggerOptions = {}) { + let level : LogLevel = options.level ?? LogLevel.WARN; + let maxLogs : number = options.maxLogs ?? 1000; + let entries : LogEntry[] = []; + + function setLevel(l: LogLevel): void { + level = l; + } + + function setMaxLogs(max: number): void { + maxLogs = max; + } + + function log( + lvl : LogLevel, + category: MessageCategory, + message : I18nString, + context?: Record + ): void { + if (lvl < level) return; + + const entry: LogEntry = { + timestamp: new Date(), + level : lvl, + category, + message, + context, + }; + + entries.push(entry); + + if (entries.length > maxLogs) { + entries.shift(); + } + + output(entry); + } + + function output(entry: LogEntry): void { + const message = i18n.ts(entry.message); + const prefix = `[${entry.category}]`; + const timestamp = entry.timestamp.toISOString(); + const ctx = entry.context ?? ''; + + switch (entry.level) { + case LogLevel.DEBUG: console.debug(timestamp, prefix, message, ctx); break; + case LogLevel.INFO: console.info (timestamp, prefix, message, ctx); break; + case LogLevel.WARN: console.warn (timestamp, prefix, message, ctx); break; + case LogLevel.ERROR: console.error(timestamp, prefix, message, ctx); break; + } + } + + function debug(category: MessageCategory, message: I18nString, context?: Record): void { + log(LogLevel.DEBUG, category, message, context); + } + + function info(category: MessageCategory, message: I18nString, context?: Record): void { + log(LogLevel.INFO, category, message, context); + } + + function warn(category: MessageCategory, message: I18nString, context?: Record): void { + log(LogLevel.WARN, category, message, context); + } + + function error(category: MessageCategory, message: I18nString, context?: Record): void { + log(LogLevel.ERROR, category, message, context); + } + + function getLogs(filters?: { + level? : LogLevel; + category?: MessageCategory; + since? : Date; + }): LogEntry[] { + let result = entries; + + if (filters?.level !== undefined) { + result = result.filter(e => e.level === filters.level); + } + if (filters?.category !== undefined) { + result = result.filter(e => e.category === filters.category); + } + if (filters?.since !== undefined) { + result = result.filter(e => e.timestamp >= filters.since!); + } + + return result; + } + + function clear(): void { + entries = []; + } + + function serialize(): string { + return JSON.stringify( + entries.map(e => ({ + ...e, + message : i18n.ts(e.message), + locale : i18n.getLocale(), + timestamp: e.timestamp.toISOString(), + })), + null, + 2 + ); + } + + return { debug, info, warn, error, getLogs, clear, serialize, setLevel, setMaxLogs }; +} + diff --git a/src/libs/logr/logr.test.ts b/src/libs/logr/logr.test.ts new file mode 100644 index 0000000..3d3db02 --- /dev/null +++ b/src/libs/logr/logr.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createLogger, LogLevel } from './index.ts'; +import { createI18n } from '@/libs/i18n'; +import type { TranslationNode, SupportedLocale } from '@/libs/i18n'; + + + +// ============================================================================ +// SCHEMA Y I18N DE TEST +// ============================================================================ + +const schema = { + auth: { + loginFailed : { es: 'Login fallido', en: 'Login failed' }, + sessionExpired: { es: 'Sesión expirada', en: 'Session expired' }, + }, + db: { + connectionError: { es: 'Error de conexión', en: 'Connection error' }, + }, +} satisfies TranslationNode; + +function makeI18n(locale: SupportedLocale = 'es') { + const i18n = createI18n(schema, 'es'); + i18n.setLocale(locale); + return i18n; +} + +// ============================================================================ +// TESTS +// ============================================================================ + +describe('createLogger', () => { + + describe('instanciación', () => { + it('crea una instancia con nivel WARN por defecto', () => { + const i18n = makeI18n(); + const logger = createLogger(i18n); + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + logger.warn('auth', { es: 'aviso' }); + logger.debug('auth', { es: 'debug' }); + + expect(warn).toHaveBeenCalledTimes(1); + expect(debug).not.toHaveBeenCalled(); + + warn.mockRestore(); + debug.mockRestore(); + }); + + it('respeta el nivel configurado en options', () => { + const i18n = makeI18n(); + const logger = createLogger(i18n, { level: LogLevel.DEBUG }); + const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + logger.debug('auth', { es: 'debug msg' }); + expect(debug).toHaveBeenCalledTimes(1); + + debug.mockRestore(); + }); + + it('instancias son independientes entre sí', () => { + const i18n = makeI18n(); + const l1 = createLogger(i18n, { level: LogLevel.DEBUG }); + const l2 = createLogger(i18n, { level: LogLevel.ERROR }); + + l1.setLevel(LogLevel.NONE); + expect(l2.getLogs()).toHaveLength(0); // no se contaminan + }); + }); + + describe('niveles de log', () => { + let i18n: ReturnType; + let logger: ReturnType; + + beforeEach(() => { + i18n = makeI18n(); + logger = createLogger(i18n, { level: LogLevel.DEBUG }); + }); + + it('debug llama a console.debug', () => { + const spy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + logger.debug('auth', { es: 'msg debug' }); + expect(spy).toHaveBeenCalledTimes(1); + spy.mockRestore(); + }); + + it('info llama a console.info', () => { + const spy = vi.spyOn(console, 'info').mockImplementation(() => {}); + logger.info('auth', { es: 'msg info' }); + expect(spy).toHaveBeenCalledTimes(1); + spy.mockRestore(); + }); + + it('warn llama a console.warn', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + logger.warn('auth', { es: 'msg warn' }); + expect(spy).toHaveBeenCalledTimes(1); + spy.mockRestore(); + }); + + it('error llama a console.error', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + logger.error('auth', { es: 'msg error' }); + expect(spy).toHaveBeenCalledTimes(1); + spy.mockRestore(); + }); + + it('no loguea si el nivel es inferior al configurado', () => { + const logger = createLogger(i18n, { level: LogLevel.ERROR }); + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + logger.warn('auth', { es: 'esto no debe salir' }); + expect(spy).not.toHaveBeenCalled(); + + spy.mockRestore(); + }); + }); + + describe('resolución i18n', () => { + it('resuelve el mensaje con el locale actual del i18n', () => { + const i18n = makeI18n('en'); + const logger = createLogger(i18n, { level: LogLevel.DEBUG }); + const spy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + logger.debug('auth', schema.auth.loginFailed); + + expect(spy).toHaveBeenCalledWith( + expect.any(String), + '[auth]', + 'Login failed', + '' + ); + + spy.mockRestore(); + }); + + it('resuelve en español cuando el locale es es', () => { + const i18n = makeI18n('es'); + const logger = createLogger(i18n, { level: LogLevel.DEBUG }); + const spy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + logger.debug('auth', schema.auth.loginFailed); + + expect(spy).toHaveBeenCalledWith( + expect.any(String), + '[auth]', + 'Login fallido', + '' + ); + + spy.mockRestore(); + }); + + it('refleja el cambio de locale automáticamente sin reconfigurar el logger', () => { + const i18n = makeI18n('es'); + const logger = createLogger(i18n, { level: LogLevel.DEBUG }); + const spy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + logger.debug('auth', schema.auth.loginFailed); + expect(spy).toHaveBeenLastCalledWith(expect.any(String), '[auth]', 'Login fallido', ''); + + // Cambiamos locale en i18n — el logger lo recoge automáticamente + i18n.setLocale('en'); + logger.debug('auth', schema.auth.loginFailed); + expect(spy).toHaveBeenLastCalledWith(expect.any(String), '[auth]', 'Login failed', ''); + + spy.mockRestore(); + }); + + it('acepta I18nString como string simple (pass-through)', () => { + const i18n = makeI18n(); + const logger = createLogger(i18n, { level: LogLevel.DEBUG }); + const spy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + logger.debug('auth', 'mensaje fijo'); + + expect(spy).toHaveBeenCalledWith( + expect.any(String), + '[auth]', + 'mensaje fijo', + '' + ); + + spy.mockRestore(); + }); + }); + + describe('getLogs()', () => { + let i18n: ReturnType; + let logger: ReturnType; + + beforeEach(() => { + i18n = makeI18n(); + logger = createLogger(i18n, { level: LogLevel.DEBUG }); + vi.spyOn(console, 'debug').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('retorna todos los logs sin filtros', () => { + logger.debug('auth', { es: 'a' }); + logger.warn ('auth', { es: 'b' }); + logger.error('db', { es: 'c' }); + expect(logger.getLogs()).toHaveLength(3); + }); + + it('filtra por nivel', () => { + logger.debug('auth', { es: 'a' }); + logger.warn ('auth', { es: 'b' }); + logger.error('db', { es: 'c' }); + expect(logger.getLogs({ level: LogLevel.WARN })).toHaveLength(1); + }); + + it('filtra por categoría', () => { + logger.debug('auth', { es: 'a' }); + logger.warn ('auth', { es: 'b' }); + logger.error('db', { es: 'c' }); + expect(logger.getLogs({ category: 'auth' })).toHaveLength(2); + }); + + it('filtra por fecha', async () => { + logger.debug('auth', { es: 'antes' }); + await new Promise(r => setTimeout(r, 10)); + const since = new Date(); + await new Promise(r => setTimeout(r, 10)); + logger.warn('auth', { es: 'después' }); + + expect(logger.getLogs({ since })).toHaveLength(1); + }); + }); + + describe('maxLogs', () => { + it('respeta el límite de entradas', () => { + const i18n = makeI18n(); + const logger = createLogger(i18n, { level: LogLevel.DEBUG, maxLogs: 3 }); + vi.spyOn(console, 'debug').mockImplementation(() => {}); + + for (let i = 0; i < 5; i++) { + logger.debug('auth', { es: `msg ${i}` }); + } + + expect(logger.getLogs()).toHaveLength(3); + }); + + it('descarta los más antiguos cuando supera el límite', () => { + const i18n = makeI18n(); + const logger = createLogger(i18n, { level: LogLevel.DEBUG, maxLogs: 2 }); + vi.spyOn(console, 'debug').mockImplementation(() => {}); + + logger.debug('auth', { es: 'primero' }); + logger.debug('auth', { es: 'segundo' }); + logger.debug('auth', { es: 'tercero' }); + + const logs = logger.getLogs(); + expect(logs[0].message).toEqual({ es: 'segundo' }); + expect(logs[1].message).toEqual({ es: 'tercero' }); + }); + }); + + describe('clear()', () => { + it('vacía el historial', () => { + const i18n = makeI18n(); + const logger = createLogger(i18n, { level: LogLevel.DEBUG }); + vi.spyOn(console, 'debug').mockImplementation(() => {}); + + logger.debug('auth', { es: 'msg' }); + logger.clear(); + + expect(logger.getLogs()).toHaveLength(0); + }); + }); + + describe('serialize()', () => { + it('exporta los logs como JSON con mensajes resueltos', () => { + const i18n = makeI18n('en'); + const logger = createLogger(i18n, { level: LogLevel.DEBUG }); + vi.spyOn(console, 'debug').mockImplementation(() => {}); + + logger.debug('auth', schema.auth.loginFailed); + + const parsed = JSON.parse(logger.serialize()); + expect(parsed[0].message).toBe('Login failed'); + expect(parsed[0].locale).toBe('en'); + }); + }); +}); \ No newline at end of file diff --git a/src/libs/logr/logr.types.ts b/src/libs/logr/logr.types.ts new file mode 100644 index 0000000..713b65f --- /dev/null +++ b/src/libs/logr/logr.types.ts @@ -0,0 +1,29 @@ + +import type { I18nString, SupportedLocale } from '@/libs/i18n'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, + NONE = 4 +} + +export type MessageCategory = string; + +export interface LogEntry { + timestamp : Date; + level : LogLevel; + category : MessageCategory; + message : I18nString; + context? : Record; +} + +export interface LoggerOptions { + level? : LogLevel; + maxLogs? : number; +}