From 8f422e2b4fea28a0913841b88bed124a4d8ae43f Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 2 Mar 2026 02:29:58 +0100 Subject: [PATCH] add glob module with numr, curr, dati, unit engines + docs Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 + .idea/.gitignore | 8 - package.json | 13 +- src/glob/comn_engine.ts | 10 + src/glob/comn_types.ts | 21 ++ src/glob/curr_engine.ts | 117 +++++++ src/glob/curr_types.ts | 63 ++++ src/glob/dati_engine.ts | 80 +++++ src/glob/dati_types.ts | 40 +++ src/glob/docs/glob.md | 431 +++++++++++++++++++++++++ src/glob/glob_engine.ts | 53 +++ src/glob/glob_types.ts | 46 +++ src/glob/numr_engine.ts | 46 +++ src/glob/numr_types.ts | 21 ++ src/glob/tests/curr.test.ts | 226 +++++++++++++ src/glob/tests/dati.test.ts | 159 +++++++++ src/glob/tests/glob.test.ts | 238 ++++++++++++++ src/glob/tests/numr.test.ts | 137 ++++++++ src/glob/tests/unit.test.ts | 241 ++++++++++++++ src/glob/unit_data.ts | 0 src/glob/unit_engine.ts | 152 +++++++++ src/glob/unit_langs.ts | 50 +++ src/glob/unit_types.ts | 70 ++++ src/ling/engine.ts | 8 +- src/logr/docs/logr.md | 2 +- src/logr/tests/logr.test.ts | 4 +- src/logr/tests/logr_transports.test.ts | 4 +- src/logr/transports.ts | 11 +- 28 files changed, 2226 insertions(+), 29 deletions(-) create mode 100644 .gitignore delete mode 100644 .idea/.gitignore create mode 100644 src/glob/comn_engine.ts create mode 100644 src/glob/comn_types.ts create mode 100644 src/glob/curr_engine.ts create mode 100644 src/glob/curr_types.ts create mode 100644 src/glob/dati_engine.ts create mode 100644 src/glob/dati_types.ts create mode 100644 src/glob/docs/glob.md create mode 100644 src/glob/glob_engine.ts create mode 100644 src/glob/glob_types.ts create mode 100644 src/glob/numr_engine.ts create mode 100644 src/glob/numr_types.ts create mode 100644 src/glob/tests/curr.test.ts create mode 100644 src/glob/tests/dati.test.ts create mode 100644 src/glob/tests/glob.test.ts create mode 100644 src/glob/tests/numr.test.ts create mode 100644 src/glob/tests/unit.test.ts create mode 100644 src/glob/unit_data.ts create mode 100644 src/glob/unit_engine.ts create mode 100644 src/glob/unit_langs.ts create mode 100644 src/glob/unit_types.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..164614e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.idea/ +package-lock.json diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/package.json b/package.json index 6095a70..a2c215c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "main": "dist/index.js", "scripts": { "build": "tsc", - "dev": "vite dev" + "dev": "vite dev", + "test": "vitest", + "test:run": "vitest run" }, "keywords": [ "configuration", @@ -16,17 +18,17 @@ "license": "EULA", "dependencies": { - "svelte": "^5.53.0", + "svelte": "^5.53.6", "@tailwindcss/vite": "^4.1.18" }, "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/postcss": "^4.1.18", "@tsconfig/svelte": "^5.0.6", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", - "@types/node": "^25.2.3", + "@types/node": "^25.3.3", "jsdom": "^28.1.0", "ts-node": "^10.9.2", "autoprefixer": "^10.4.23", @@ -37,5 +39,6 @@ "vite-plugin-singlefile": "^2.3.0", "vitest": "^4.0.18" }, - "private": true + "private": true, + "type": "module" } diff --git a/src/glob/comn_engine.ts b/src/glob/comn_engine.ts new file mode 100644 index 0000000..f033149 --- /dev/null +++ b/src/glob/comn_engine.ts @@ -0,0 +1,10 @@ +// ============================== +// HELPERS +// ============================== + +import type { GlobLocale, LocaleResolver } from "./comn_types"; + +/** Normaliza un `LocaleResolver` a un string de locale listo para `Intl.*`. */ +export function resolveLocale(l: LocaleResolver): GlobLocale { + return typeof l === 'function' ? l() : l; +} \ No newline at end of file diff --git a/src/glob/comn_types.ts b/src/glob/comn_types.ts new file mode 100644 index 0000000..37b52e8 --- /dev/null +++ b/src/glob/comn_types.ts @@ -0,0 +1,21 @@ +import type { LingInstance, LingNode, LingRecord, LingPluralFn, SupportedLocale } from '@/ling/types'; + +// ============================== +// LOCALE +// ============================== + +/** Cualquier código BCP 47 válido ('es', 'en', 'es-MX', …). */ +export type GlobLocale = SupportedLocale | (string & {}); + +/** + * Valor directo o función — los módulos llaman a `resolveLocale()` en cada `f()`. + * + * - String fijo: el locale se resuelve una sola vez en la llamada. + * - Función: el locale se lee en cada invocación, lo que permite reactividad + * sin necesidad de recrear los módulos. + * + * @example + * numr.f(1234, {}, 'en') // fijo + * numr.f(1234, {}, () => user.locale) // dinámico + */ +export type LocaleResolver = GlobLocale | (() => GlobLocale); diff --git a/src/glob/curr_engine.ts b/src/glob/curr_engine.ts new file mode 100644 index 0000000..a86ef62 --- /dev/null +++ b/src/glob/curr_engine.ts @@ -0,0 +1,117 @@ +import type { CurrInstance, CurrDefinition, CurrDisplay, CurrConfig, ConversionValue } from './curr_types'; +import type { LingInstance, LingNode } from '@/ling/types'; +import type { LocaleResolver, GlobLocale } from './comn_types'; +import { resolveLocale } from './comn_engine'; +import { defaultISOLocale } from '@/ling/consts'; + + +/** + * Crea un módulo de moneda con formateo y conversión. + * + * @param definitions - Mapa de monedas disponibles indexado por código ISO. + * @param ling - Instancia de ling, usada para la pluralización de nombres. + * @param opts - Configuración inicial (moneda activa, decimales, display). + * @param defaultLocale - Locale o resolver usado cuando `f()` no recibe locale explícito. + */ +export function createCurr( + definitions : Record, + ling : LingInstance, + opts : CurrConfig = {}, + defaultLocale: LocaleResolver = defaultISOLocale +): CurrInstance { + + let selectedCurrency: string = opts.selectedCurrency ?? Object.keys(definitions)[0] ?? ''; + const baseCurrency : string = opts.baseCurrency ?? selectedCurrency; + + const defaults = { + display : opts.display ?? 'symbol' as CurrDisplay, + decimals : opts.decimals ?? 2, + }; + + // ── Currency ────────────────────────────────────────────────────────────── + + function getCurrency(): string { return selectedCurrency; } + function setCurrency(currency: string): void { selectedCurrency = currency.toUpperCase(); } + + /** Busca una definición por código ISO, insensible a mayúsculas. */ + function get(currency: string): CurrDefinition | undefined { + return definitions[currency.toUpperCase()]; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + function formatAmount(amount: number, decimals: number, locale: GlobLocale): string { + return new Intl.NumberFormat(locale, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(amount); + } + + /** Resuelve el nombre plural de la moneda según `amount` y el idioma del locale. */ + function resolveName(def: CurrDefinition, amount: number, locale: GlobLocale): string { + const record = def.name({ count: Math.abs(amount) }); + const baseLocale = locale.split('-')[0] as keyof typeof record; + return record[baseLocale] ?? record['es']; + } + + /** Normaliza `ConversionValue` a número o `null`. Evalúa la función si es dinámica. */ + function resolveConversionValue(value: ConversionValue): number | null { + if (value === null || value === undefined) return null; + if (typeof value === 'number') return value; + return value(); + } + + // ── Format ──────────────────────────────────────────────────────────────── + + /** + * Formatea `amount` en la moneda activa. + * Si la moneda no está registrada, devuelve el importe seguido del código ISO. + */ + function f( + amount : number, + display : CurrDisplay = defaults.display, + locale : LocaleResolver = defaultLocale + ): string { + const loc = resolveLocale(locale); + const formatted = formatAmount(amount, defaults.decimals, loc); + const def = get(selectedCurrency); + + if (!def) return `${formatted} ${selectedCurrency}`; + + switch (display) { + case 'symbol': return `${formatted} ${def.symbol}`; + case 'code': return `${formatted} ${def.code}`; + case 'name': return `${formatted} ${resolveName(def, amount, loc)}`; + } + } + + // ── Conversión ──────────────────────────────────────────────────────────── + + /** + * Convierte `amount` de `from` a `to` usando sus tasas respecto a la moneda base. + * Fórmula: `(amount / fromRate) * toRate` + * Devuelve `null` si alguna moneda no tiene tasa de conversión. + */ + function convert(amount: number, from: string, to: string): number | null { + if (from.toUpperCase() === to.toUpperCase()) return amount; + + const fromDef = get(from); + const toDef = get(to); + if (!fromDef || !toDef) return null; + + const fromRate = resolveConversionValue(fromDef.conversionValue ?? null); + const toRate = resolveConversionValue(toDef.conversionValue ?? null); + if (fromRate === null || toRate === null) return null; + + // Convertir a moneda base y luego a destino + const inBase = amount / fromRate; + return inBase * toRate; + } + + /** Convierte desde la moneda activa (`selectedCurrency`) hacia `to`. */ + function convertTo(amount: number, to: string): number | null { + return convert(amount, selectedCurrency, to); + } + + return { f, convert, convertTo, setCurrency, getCurrency, get }; +} diff --git a/src/glob/curr_types.ts b/src/glob/curr_types.ts new file mode 100644 index 0000000..de9e466 --- /dev/null +++ b/src/glob/curr_types.ts @@ -0,0 +1,63 @@ +// ============================== +// CURR TYPES +// ============================== + +import type { LingPluralFn } from "../ling"; +import type { LocaleResolver } from "./comn_types"; + + +/** + * Tasa de conversión de una moneda respecto a la moneda base. + * - `number` : tasa estática. + * - `() => number | null` : tasa dinámica (ej. consultada en tiempo real). + * - `null` : conversión no disponible para esta moneda. + */ +export type ConversionValue = number | (() => number | null) | null; + +/** Cómo se muestra la moneda en `f()`. */ +export type CurrDisplay = 'symbol' | 'code' | 'name'; + +/** Definición completa de una moneda. */ +export interface CurrDefinition { + /** Símbolo gráfico, ej. `'€'`, `'$'`. */ + symbol : string; + /** Función plural bilingüe para el nombre de la moneda (ej. "euro" / "euros"). */ + name : LingPluralFn>; + /** Código ISO 4217, ej. `'EUR'`, `'USD'`. */ + code : string; + /** Tasa respecto a la moneda base. Omitir o `null` deshabilita la conversión. */ + conversionValue?: ConversionValue; +} + +export interface CurrInstance { + /** Formatea `amount` en la moneda activa según `display` y `locale`. */ + f : (amount: number, display?: CurrDisplay, locale?: LocaleResolver) => string; + /** + * Convierte `amount` de la moneda `from` a la moneda `to`. + * Devuelve `null` si alguna de las monedas no tiene `conversionValue`. + * Fórmula: `(amount / fromRate) * toRate` + */ + convert : (amount: number, from: string, to: string) => number | null; + /** Convierte `amount` desde la moneda activa (`selectedCurrency`) hacia `to`. */ + convertTo : (amount: number, to: string) => number | null; + /** Cambia la moneda activa. Acepta mayúsculas y minúsculas. */ + setCurrency: (currency: string) => void; + /** Devuelve el código ISO de la moneda activa. */ + getCurrency: () => string; + /** Busca la definición de una moneda por código ISO (insensible a mayúsculas). */ + get : (currency: string) => CurrDefinition | undefined; +} + +/** Configuración de `createCurr`. */ +export interface CurrConfig { + /** Mapa de definiciones de monedas indexado por código ISO. */ + definitions? : Record; + /** Moneda base para los cálculos de conversión. Default: `selectedCurrency`. */ + baseCurrency? : string; + /** Moneda activa al iniciar. Default: primera moneda de `definitions`. */ + selectedCurrency?: string; + /** Modo de display por defecto. Default: `'symbol'`. */ + display? : CurrDisplay; + /** Decimales fijos para el formateo. Default: `2`. */ + decimals? : number; +} diff --git a/src/glob/dati_engine.ts b/src/glob/dati_engine.ts new file mode 100644 index 0000000..f78faf9 --- /dev/null +++ b/src/glob/dati_engine.ts @@ -0,0 +1,80 @@ +import type { DatiInstance, DatiOptions, DatiStyleType, DatiTimeFormat, DatiDateOrder } from './dati_types'; +import type { LocaleResolver, GlobLocale } from './comn_types'; +import { resolveLocale } from './comn_engine'; +import { defaultISOLocale } from '@/ling/consts'; + + +// ============================== +// HELPERS +// ============================== + +/** + * Normaliza `DatiOptions` a las opciones que acepta `Intl.DateTimeFormat`. + * - Sin opciones → solo fecha larga. + * - String → solo fecha con ese estilo. + * - Objeto → combina `date` y `time` según lo indicado. + */ +function resolveOpts(opts?: DatiOptions): { + dateStyle?: DatiStyleType; + timeStyle?: DatiStyleType; +} { + if (!opts) return { dateStyle: 'long' }; + if (typeof opts === 'string') return { dateStyle: opts }; + const { date, time } = opts; + if (!date && !time) return { dateStyle: 'long' }; + return { dateStyle: date, timeStyle: time }; +} + +// ============================== +// ENGINE +// ============================== + +/** + * Crea un formateador de fechas y horas basado en `Intl.DateTimeFormat`. + * + * @param opts - Preferencias iniciales (formato de hora, orden de fecha). + * @param defaultLocale - Locale o resolver usado cuando `f()` no recibe locale explícito. + */ +export function createDati( + opts : { selectedTimeFormat?: DatiTimeFormat; selectedDateOrder?: DatiDateOrder } = {}, + defaultLocale: LocaleResolver = defaultISOLocale +): DatiInstance { + + let timeFormat: DatiTimeFormat = opts.selectedTimeFormat ?? '24h'; + let dateOrder : DatiDateOrder = opts.selectedDateOrder ?? 'DMY'; + + // ── Preferencias ────────────────────────────────────────────────────────── + + function getTimeFormat(): DatiTimeFormat { return timeFormat; } + function setTimeFormat(format: DatiTimeFormat): void { timeFormat = format; } + + function getDateOrder(): DatiDateOrder { return dateOrder; } + function setDateOrder(order: DatiDateOrder): void { dateOrder = order; } + + // ── Format ──────────────────────────────────────────────────────────────── + + /** + * Formatea `date` usando `Intl.DateTimeFormat`. + * Cuando se incluye hora, aplica `hour12` según `timeFormat` de la instancia. + */ + function f( + date : Date, + callOpts: DatiOptions = 'long', + locale : LocaleResolver = defaultLocale + ): string { + const { dateStyle, timeStyle } = resolveOpts(callOpts); + const loc = resolveLocale(locale); + + const intlOpts: Intl.DateTimeFormatOptions = {}; + + if (dateStyle) intlOpts.dateStyle = dateStyle; + if (timeStyle) { + intlOpts.timeStyle = timeStyle; + intlOpts.hour12 = timeFormat === '12h'; + } + + return new Intl.DateTimeFormat(loc, intlOpts).format(date); + } + + return { f, getTimeFormat, setTimeFormat, getDateOrder, setDateOrder }; +} diff --git a/src/glob/dati_types.ts b/src/glob/dati_types.ts new file mode 100644 index 0000000..e2c5ea9 --- /dev/null +++ b/src/glob/dati_types.ts @@ -0,0 +1,40 @@ + +// ============================== +// DATI +// ============================== + +import type { LocaleResolver } from "./comn_types"; + +/** Nivel de detalle en el formateo de fecha u hora. */ +export type DatiStyleType = 'short' | 'long'; +/** Formato de horas: reloj de 12 o 24 horas. */ +export type DatiTimeFormat = '12h' | '24h'; +/** Orden preferido de los componentes de fecha (informativo para la UI). */ +export type DatiDateOrder = 'DMY' | 'MDY' | 'YMD'; + +/** + * Opciones de `f()`: + * - `'long'` / `'short'` : solo fecha en ese estilo. + * - `{ date?, time? }` : control independiente de fecha y hora. + */ +export type DatiOptions = + | DatiStyleType + | { date?: DatiStyleType; time?: DatiStyleType }; + +/** Configuración inicial de `createDati`. */ +export interface DatiConfig { + /** Formato de horas. Default: `'24h'`. */ + selectedTimeFormat?: DatiTimeFormat; + /** Orden de los componentes de fecha. Default: `'DMY'`. */ + selectedDateOrder?: DatiDateOrder; +} + +export interface DatiInstance { + /** Formatea `date` según las opciones y el locale indicados. */ + f : (date: Date, opts?: DatiOptions, locale?: LocaleResolver) => string; + setTimeFormat: (format: DatiTimeFormat) => void; + getTimeFormat: () => DatiTimeFormat; + /** Guarda la preferencia de orden de fecha para uso de la UI. No afecta a `Intl`. */ + setDateOrder : (order: DatiDateOrder) => void; + getDateOrder : () => DatiDateOrder; +} diff --git a/src/glob/docs/glob.md b/src/glob/docs/glob.md new file mode 100644 index 0000000..4c5f509 --- /dev/null +++ b/src/glob/docs/glob.md @@ -0,0 +1,431 @@ +# glob — Módulo de globalización + +`glob` es la capa de globalización del proyecto. Agrupa cuatro módulos de formateo y conversión —número, moneda, fecha/hora y unidades— bajo un único punto de entrada con locale compartido. + +--- + +## Índice + +1. [Diseño](#diseño) +2. [LocaleResolver](#localeresolver) +3. [createGlob](#createglob) +4. [numr — Números](#numr) +5. [curr — Moneda](#curr) +6. [dati — Fecha y hora](#dati) +7. [unit — Unidades](#unit) +8. [Referencia de tipos](#referencia-de-tipos) + +--- + +## Diseño + +### Principios + +- **Un locale, todos los módulos.** El locale vive en la instancia `ling`. Cuando se llama a `glob.setLocale()`, todos los módulos formatean con el nuevo locale de forma automática, sin necesidad de recrearlos. +- **Módulos independientes.** Cada módulo (`numr`, `curr`, `dati`, `unit`) puede instanciarse de forma autónoma con `createNumr`, `createCurr`, etc., sin necesitar `createGlob`. +- **Locale reactivo.** Cada función `f()` acepta un `LocaleResolver` opcional. Si se omite, usa el default configurado al crear la instancia (que puede ser una función que lee el locale en tiempo de ejecución). +- **Fábricas puras.** No hay clases ni singletons. Cada llamada a `createX()` devuelve un objeto cerrado con su estado interno. + +### Estructura de archivos + +``` +src/glob/ +├── comn_types.ts — GlobLocale, LocaleResolver +├── comn_engine.ts — resolveLocale() +├── glob_types.ts — GlobConfig, GlobInstance +├── glob_engine.ts — createGlob() +├── numr_types.ts — NumrConfig, NumrInstance +├── numr_engine.ts — createNumr() +├── curr_types.ts — CurrDefinition, CurrConfig, CurrInstance +├── curr_engine.ts — createCurr() +├── dati_types.ts — DatiConfig, DatiInstance, DatiOptions +├── dati_engine.ts — createDati() +├── unit_types.ts — UnitCategory, UnitConfig, UnitInstance +├── unit_engine.ts — createUnit() +└── unit_langs.ts — defaultCategories (definiciones por defecto) +``` + +### Flujo de locale en `createGlob` + +``` +glob.setLocale('en') + └─▶ ling.setLocale('en') // fuente de verdad + └─▶ listeners.forEach(fn => fn()) // notifica suscriptores externos + +glob.numr.f(1234) + └─▶ resolveLocale(getLocale) // llama a ling.getLocale() en cada f() + └─▶ Intl.NumberFormat('en').format(1234) +``` + +Los módulos reciben `getLocale` (una función `() => GlobLocale`) como `defaultLocale`, por lo que leen el locale actual en cada invocación sin necesidad de reconectarse. + +--- + +## LocaleResolver + +```ts +type LocaleResolver = GlobLocale | (() => GlobLocale); +``` + +Todos los métodos `f()` aceptan un `LocaleResolver` como último parámetro. Puede ser: + +| Forma | Cuándo usarla | +|---|---| +| `'es'` | Locale fijo en esa llamada concreta | +| `() => user.locale` | Locale dinámico que se lee en cada llamada | +| *(omitido)* | Usa el `defaultLocale` configurado al crear la instancia | + +`resolveLocale(l)` en `comn_engine.ts` normaliza ambas formas: + +```ts +resolveLocale('es') // → 'es' +resolveLocale(() => 'en') // → 'en' +``` + +--- + +## createGlob + +Punto de entrada principal. Crea una instancia integrada con locale compartido. + +```ts +import { createGlob } from '@/glob/glob_engine'; + +const glob = createGlob({ + locale: 'es', + ling, // instancia de LingInstance + numr: { maxDecimals: 2 }, + curr: { + definitions: { EUR: eurDef, USD: usdDef }, + selectedCurrency: 'EUR', + }, + dati: { selectedTimeFormat: '24h' }, + unit: { selectedSystem: 'metric' }, +}); +``` + +### API de GlobInstance + +| Método / propiedad | Descripción | +|---|---| +| `getLocale()` | Locale activo (delegado a `ling`) | +| `setLocale(locale)` | Cambia el locale en `ling` y notifica suscriptores | +| `onLocaleChange(fn)` | Suscribe un listener; devuelve función de desuscripción | +| `t(key, params?)` | Traducción via `ling.t` | +| `ts(key, params?)` | Traducción con soporte plural via `ling.ts` | +| `ling` | La instancia `LingInstance` completa | +| `numr` | Módulo de números | +| `curr` | Módulo de moneda | +| `dati` | Módulo de fecha/hora | +| `unit` | Módulo de unidades | + +### Cambio reactivo de locale + +```ts +glob.setLocale('en'); + +glob.numr.f(1234.5); // '1,234.5' +glob.curr.f(99); // '99.00 €' +glob.dati.f(new Date()); // 'March 2, 2026' +``` + +### Suscriptor de cambios + +```ts +const unsub = glob.onLocaleChange(locale => { + console.log('Nuevo locale:', locale); +}); + +glob.setLocale('fr'); // → 'Nuevo locale: fr' +unsub(); // elimina el listener +``` + +--- + +## numr + +Formateo de números usando `Intl.NumberFormat`. + +### Creación independiente + +```ts +import { createNumr } from '@/glob/numr_engine'; + +const numr = createNumr({ minDecimals: 0, maxDecimals: 2 }, 'es'); +``` + +### `f(n, opts?, locale?)` + +```ts +numr.f(1234567.89) // '1.234.567,89' (es) +numr.f(1234567.89, {}, 'en') // '1,234,567.89' +numr.f(1234.5, { maxDecimals: 0 }) // '1.235' +numr.f(0.1234, { minDecimals: 2, maxDecimals: 4 }) // '0,1234' +``` + +| Parámetro | Tipo | Default | +|---|---|---| +| `n` | `number` | — | +| `opts.minDecimals` | `number` | `0` | +| `opts.maxDecimals` | `number` | `2` | +| `locale` | `LocaleResolver` | defaultLocale de instancia | + +--- + +## curr + +Formateo y conversión de moneda. + +### Definir monedas + +```ts +import type { CurrDefinition } from '@/glob/curr_types'; +import { p } from '@/ling/engine'; + +const EUR: CurrDefinition = { + symbol: '€', + code: 'EUR', + name: p({ + es: { one: 'euro', other: 'euros' }, + en: { one: 'euro', other: 'euros' }, + }), + conversionValue: 1, // relativo a moneda base +}; +``` + +### Creación independiente + +```ts +import { createCurr } from '@/glob/curr_engine'; + +const curr = createCurr( + { EUR, USD, MXN }, // definiciones + ling, // instancia LingInstance + { selectedCurrency: 'EUR', decimals: 2 }, + 'es' // defaultLocale +); +``` + +### `f(amount, display?, locale?)` + +```ts +curr.f(99.5) // '99,50 €' (symbol, es) +curr.f(99.5, 'symbol', 'en') // '99.50 €' +curr.f(99.5, 'code') // '99,50 EUR' +curr.f(1, 'name') // '1,00 euro' +curr.f(2, 'name') // '2,00 euros' +``` + +| `display` | Resultado ejemplo | +|---|---| +| `'symbol'` (default) | `99,50 €` | +| `'code'` | `99,50 EUR` | +| `'name'` | `99,50 euros` | + +### Conversión + +```ts +curr.convert(100, 'EUR', 'USD') // → 108 (si USD.conversionValue = 1.08) +curr.convertTo(100, 'USD') // convierte desde selectedCurrency +``` + +`conversionValue` puede ser un número estático o una función `() => number` para tasas dinámicas: + +```ts +const USD: CurrDefinition = { + // ... + conversionValue: () => fetchLiveRate('USD'), +}; +``` + +### Gestión de moneda activa + +```ts +curr.getCurrency() // 'EUR' +curr.setCurrency('USD') // cambia la moneda activa +curr.get('EUR') // → CurrDefinition | undefined +``` + +--- + +## dati + +Formateo de fechas y horas usando `Intl.DateTimeFormat`. + +### Creación independiente + +```ts +import { createDati } from '@/glob/dati_engine'; + +const dati = createDati({ selectedTimeFormat: '24h' }, 'es'); +``` + +### `f(date, opts?, locale?)` + +```ts +const d = new Date('2026-03-02T15:30:00'); + +dati.f(d) // '2 de marzo de 2026' (long, es) +dati.f(d, 'short') // '2/3/26' +dati.f(d, { date: 'long', time: 'short' }) // '2 de marzo de 2026, 15:30' +dati.f(d, { time: 'short' }, 'en') // '3:30 PM' (si timeFormat = '12h') +``` + +#### `DatiOptions` + +| Valor | Resultado | +|---|---| +| `'long'` (default) | Solo fecha en formato largo | +| `'short'` | Solo fecha en formato corto | +| `{ date: 'long' }` | Solo fecha larga | +| `{ time: 'short' }` | Solo hora corta | +| `{ date: 'long', time: 'short' }` | Fecha y hora | + +### Preferencias + +```ts +dati.setTimeFormat('12h') // '12h' | '24h' +dati.getTimeFormat() // '12h' + +dati.setDateOrder('MDY') // 'DMY' | 'MDY' | 'YMD' +dati.getDateOrder() // 'MDY' +``` + +> `dateOrder` está almacenado en el estado pero el orden real lo gestiona `Intl.DateTimeFormat` según el locale. Se expone para que la UI pueda reflejarlo. + +--- + +## unit + +Conversión y formateo de unidades de medida. Soporta dos sistemas: `metric` e `imperial`. + +### Categorías disponibles + +| Categoría | Base métrica | Base imperial | Unidades | +|---|---|---|---| +| `weight` | `kg` | `lb` | kg, g, lb, oz, t | +| `length` | `m` | `mi` | km, m, cm, mm, mi, ft, in, yd | +| `temp` | `c` | `f` | c, f, k | +| `volume` | `l` | `gal` | l, ml, m³, gal, fl oz, pt | +| `area` | `m²` | `ft²` | m², km², ha, ft², mi², ac | + +### Creación independiente + +```ts +import { createUnit } from '@/glob/unit_engine'; + +const unit = createUnit({}, { selectedSystem: 'metric' }, 'es'); +``` + +### Sistema activo + +```ts +unit.setSystem('imperial') +unit.getSystem() // 'imperial' +``` + +### `category.f(value, display?, locale?)` + +Formatea el valor en la unidad base del sistema activo: + +```ts +unit.weight.f(70) // '70 kg' (metric, symbol) +unit.weight.f(70, 'name') // '70 kilogramos' +unit.weight.f(70, 'symbol', 'en') // '70 kg' + +unit.setSystem('imperial'); +unit.weight.f(70) // '154,32 lb' +``` + +### `category.fTo(value, targetUnit?, display?, locale?)` + +Convierte desde la base métrica y formatea en la unidad destino: + +```ts +unit.length.fTo(1000, 'km') // '1 km' +unit.temp.fTo(100, 'f') // '212 °F' (100°C → °F) +unit.area.fTo(10000, 'ha') // '1 ha' +``` + +Si se omite `targetUnit`, usa la base del sistema activo. + +### `category.convert(value, from, to)` + +Conversión numérica pura, sin formateo: + +```ts +unit.weight.convert(1, 'kg', 'lb') // 2.204... +unit.temp.convert(100, 'c', 'f') // 212 +unit.length.convert(1, 'mi', 'km') // 1.609... +``` + +La temperatura usa conversión directa (no por factor lineal), manejada internamente con `convertTemp()`. + +### Personalizar categorías + +Se pueden sobreescribir las categorías por defecto o añadir nuevas unidades: + +```ts +const unit = createUnit( + { + weight: { + base: 'kg', + baseImperial: 'lb', + units: { + // solo las unidades que necesitas + kg: { factor: 1, system: 'metric', symbol: { es: 'kg', en: 'kg' }, name: ... }, + }, + }, + }, + { selectedSystem: 'metric' }, + 'es' +); +``` + +--- + +## Referencia de tipos + +### `GlobLocale` + +```ts +type GlobLocale = SupportedLocale | (string & {}); +``` + +Acepta cualquier código BCP 47 (`'es'`, `'en'`, `'fr'`, `'es-MX'`, etc.). + +### `LocaleResolver` + +```ts +type LocaleResolver = GlobLocale | (() => GlobLocale); +``` + +### `CurrDefinition` + +```ts +interface CurrDefinition { + symbol : string; + code : string; + name : LingPluralFn>; + conversionValue?: number | (() => number | null) | null; +} +``` + +### `UnitDefinition` + +```ts +interface UnitDefinition { + symbol : LingRecord; // { es: 'kg', en: 'kg' } + name : LingPluralFn<...>; // plural bilingüe + factor : number; // relativo a la base de la categoría + system : 'metric' | 'imperial'; +} +``` + +La conversión entre unidades (excepto temperatura) se calcula como: + +``` +resultado = (valor × factor_origen) / factor_destino +``` + +donde `factor` es la equivalencia de cada unidad respecto a la base de la categoría (ej. `kg` tiene factor `1`, `g` tiene factor `0.001`). diff --git a/src/glob/glob_engine.ts b/src/glob/glob_engine.ts new file mode 100644 index 0000000..10f94e3 --- /dev/null +++ b/src/glob/glob_engine.ts @@ -0,0 +1,53 @@ +import type { GlobConfig, GlobInstance } from './glob_types'; +import type { LingNode } from '@/ling/types'; +import { createNumr } from './numr_engine'; +import { createCurr } from './curr_engine'; +import { createDati } from './dati_engine'; +import { createUnit } from './unit_engine'; +import type { GlobLocale } from './comn_types'; + +/** + * Crea una instancia integrada de globalización. + * + * El locale vive en `ling` — `glob` delega siempre a `ling.getLocale()`. + * Todos los módulos reciben `getLocale` como `defaultLocale`, por lo que + * leen el locale actual en cada llamada a `f()` sin necesidad de reconectarse. + */ +export function createGlob(config: GlobConfig): GlobInstance { + + const { ling } = config; + const listeners = new Set<(locale: GlobLocale) => void>(); + + // Fuente de verdad: siempre lee de ling, nunca almacena el locale localmente. + const getLocale = (): GlobLocale => ling.getLocale() as GlobLocale; + + // Cada módulo recibe `getLocale` como resolver dinámico de locale. + const numr = createNumr(config.numr ?? {}, getLocale); + const curr = createCurr(config.curr?.definitions ?? {}, ling, config.curr ?? {}, getLocale); + const dati = createDati(config.dati ?? {}, getLocale); + const unit = createUnit(config.unit?.categories ?? {}, config.unit ?? {}, getLocale); + + function setLocale(locale: GlobLocale): void { + ling.setLocale(locale as any); + listeners.forEach(fn => fn(locale)); + } + + /** Suscribe un listener al cambio de locale. Devuelve la función de desuscripción. */ + function onLocaleChange(fn: (locale: GlobLocale) => void): () => void { + listeners.add(fn); + return () => listeners.delete(fn); + } + + return { + getLocale, + setLocale, + onLocaleChange, + t : ling.t.bind(ling), + ts : ling.ts.bind(ling), + ling, + numr, + curr, + dati, + unit, + }; +} diff --git a/src/glob/glob_types.ts b/src/glob/glob_types.ts new file mode 100644 index 0000000..a4a703a --- /dev/null +++ b/src/glob/glob_types.ts @@ -0,0 +1,46 @@ +// ============================== +// GLOB +// ============================== + +import type { LingInstance, LingNode } from "../ling"; +import type { CurrConfig, CurrInstance } from "./curr_types"; +import type { DatiConfig, DatiInstance } from "./dati_types"; +import type { NumrConfig, NumrInstance } from "./numr_types"; +import type { GlobLocale } from "./comn_types"; +import type { UnitConfig, UnitInstance } from "./unit_types"; + + +/** Configuración de `createGlob`. Todos los módulos son opcionales. */ +export interface GlobConfig { + locale : GlobLocale; + /** Instancia de `ling` — es la fuente de verdad del locale compartido. */ + ling : LingInstance; + numr? : NumrConfig; + curr? : CurrConfig; + dati? : DatiConfig; + unit? : UnitConfig; +} + + +/** Instancia devuelta por `createGlob`. Punto de entrada unificado de globalización. */ +export interface GlobInstance { + /** Cambia el locale en `ling` y notifica a todos los suscriptores registrados. */ + setLocale : (locale: GlobLocale) => void; + /** Locale activo, delegado a `ling.getLocale()`. */ + getLocale : () => GlobLocale; + /** + * Suscribe un callback al cambio de locale. + * @returns Función de desuscripción. + */ + onLocaleChange: (fn: (locale: GlobLocale) => void) => () => void; + /** Traducción simple, delegada a `ling.t`. */ + t : LingInstance['t']; + /** Traducción con plural, delegada a `ling.ts`. */ + ts : LingInstance['ts']; + numr : NumrInstance; + curr : CurrInstance; + dati : DatiInstance; + unit : UnitInstance; + /** Instancia `ling` subyacente, accesible para uso directo. */ + ling : LingInstance; +} diff --git a/src/glob/numr_engine.ts b/src/glob/numr_engine.ts new file mode 100644 index 0000000..fb567a3 --- /dev/null +++ b/src/glob/numr_engine.ts @@ -0,0 +1,46 @@ +import { defaultISOLocale } from '@/ling/consts'; +import { resolveLocale } from './comn_engine'; +import type { LocaleResolver } from './comn_types'; +import type { NumrInstance } from './numr_types'; + + +// ============================== +// ENGINE +// ============================== + +/** + * Crea un formateador de números basado en `Intl.NumberFormat`. + * + * @param opts - Decimales por defecto de la instancia. + * @param defaultLocale - Locale o resolver usado cuando `f()` no recibe locale explícito. + */ +export function createNumr( + opts : { minDecimals?: number; maxDecimals?: number } = {}, + defaultLocale: LocaleResolver = defaultISOLocale +): NumrInstance { + + const defaults = { + minDecimals: opts.minDecimals ?? 0, + maxDecimals: opts.maxDecimals ?? 2, + }; + + /** + * Formatea `n` con el locale y los decimales indicados. + * Los `callOpts` tienen prioridad sobre los defaults de la instancia. + */ + function f( + n : number, + callOpts?: { minDecimals?: number; maxDecimals?: number }, + locale : LocaleResolver = defaultLocale + ): string { + const minDecimals = callOpts?.minDecimals ?? defaults.minDecimals; + const maxDecimals = callOpts?.maxDecimals ?? defaults.maxDecimals; + + return new Intl.NumberFormat(resolveLocale(locale), { + minimumFractionDigits: minDecimals, + maximumFractionDigits: maxDecimals, + }).format(n); + } + + return { f }; +} diff --git a/src/glob/numr_types.ts b/src/glob/numr_types.ts new file mode 100644 index 0000000..ed50c0c --- /dev/null +++ b/src/glob/numr_types.ts @@ -0,0 +1,21 @@ +// ============================== +// NUMR TYPES +// ============================== + +import type { LocaleResolver } from "./comn_types"; + +export interface NumrInstance { + /** + * Formatea un número según el locale activo. + * Los `opts` de cada llamada sobreescriben los defaults de la instancia. + */ + f: (n: number, opts?: { minDecimals?: number; maxDecimals?: number }, locale?: LocaleResolver) => string; +} + +/** Defaults de decimales para la instancia. Pueden sobreescribirse en cada llamada a `f()`. */ +export interface NumrConfig { + /** Mínimo de decimales mostrados. Default: `0`. */ + minDecimals?: number; + /** Máximo de decimales mostrados. Default: `2`. */ + maxDecimals?: number; +} diff --git a/src/glob/tests/curr.test.ts b/src/glob/tests/curr.test.ts new file mode 100644 index 0000000..99369da --- /dev/null +++ b/src/glob/tests/curr.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from 'vitest'; +import { createCurr } from '@/glob/curr_engine'; +import { createLing, p } from '@/ling/engine'; +import type { CurrDefinition } from '../curr_types'; + +// ============================================================================= +// FIXTURES +// ============================================================================= + +const ling = createLing({ common: { ok: { es: 'Aceptar', en: 'OK' } } } as const, 'es'); + +const definitions: Record = { + EUR: { + symbol : '€', + code : 'EUR', + name : p({ es: { one: 'euro', other: 'euros' }, en: { one: 'euro', other: 'euros' } }), + conversionValue: 1, + }, + USD: { + symbol : '$', + code : 'USD', + name : p({ es: { one: 'dólar', other: 'dólares' }, en: { one: 'dollar', other: 'dollars' } }), + conversionValue: 1.08, + }, + MXN: { + symbol : '$', + code : 'MXN', + name : p({ es: { one: 'peso mexicano', other: 'pesos mexicanos' }, en: { one: 'Mexican peso', other: 'Mexican pesos' } }), + conversionValue: 18.5, + }, + GBP: { + symbol : '£', + code : 'GBP', + name : p({ es: { one: 'libra esterlina', other: 'libras esterlinas' }, en: { one: 'pound sterling', other: 'pounds sterling' } }), + conversionValue: 0.85, + }, + JPY: { + symbol : '¥', + code : 'JPY', + name : p({ es: { one: 'yen', other: 'yenes' }, en: { one: 'yen', other: 'yen' } }), + conversionValue: null, // sin conversión disponible + }, +}; + +// ============================================================================= +// CURR — Formato y conversión de moneda +// ============================================================================= + +describe('createCurr', () => { + + describe('selectedCurrency', () => { + + it('usa la primera moneda definida por defecto', () => { + const curr = createCurr(definitions, ling, {}, 'es'); + expect(curr.getCurrency()).toBe('EUR'); + }); + + it('acepta selectedCurrency en la configuración', () => { + const curr = createCurr(definitions, ling, { selectedCurrency: 'MXN' }, 'es'); + expect(curr.getCurrency()).toBe('MXN'); + }); + + it('setCurrency cambia la moneda activa', () => { + const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es'); + curr.setCurrency('USD'); + expect(curr.getCurrency()).toBe('USD'); + }); + + it('setCurrency acepta minúsculas', () => { + const curr = createCurr(definitions, ling, {}, 'es'); + curr.setCurrency('gbp'); + expect(curr.getCurrency()).toBe('GBP'); + }); + + }); + + describe('f() — display symbol (por defecto)', () => { + + it('formatea con símbolo en es', () => { + const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es'); + expect(curr.f(99.5)).toBe('99,50 €'); + }); + + it('formatea con símbolo en en', () => { + const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'en'); + expect(curr.f(99.5)).toBe('99.50 €'); + }); + + it('usa selectedCurrency activa', () => { + const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es'); + curr.setCurrency('GBP'); + expect(curr.f(99.5)).toBe('99,50 £'); + }); + + }); + + describe('f() — display code', () => { + + it('formatea con código ISO', () => { + const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es'); + expect(curr.f(99.5, 'code')).toBe('99,50 EUR'); + }); + + }); + + describe('f() — display name (pluralización)', () => { + + it('singular en es', () => { + const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es'); + expect(curr.f(1, 'name')).toBe('1,00 euro'); + }); + + it('plural en es', () => { + const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es'); + expect(curr.f(99.5, 'name')).toBe('99,50 euros'); + }); + + it('singular en en', () => { + const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'en'); + expect(curr.f(1, 'name')).toBe('1.00 euro'); + }); + + it('plural MXN en es', () => { + const curr = createCurr(definitions, ling, { selectedCurrency: 'MXN' }, 'es'); + expect(curr.f(2, 'name')).toBe('2,00 pesos mexicanos'); + }); + + }); + + describe('f() — locale reactivo via función', () => { + + it('responde a cambios de locale sin recrear el módulo', () => { + let locale = 'es'; + const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, () => locale); + + expect(curr.f(99.5)).toContain(',50 €'); + + locale = 'en'; + expect(curr.f(99.5)).toContain('.50 €'); + }); + + }); + + describe('f() — moneda no registrada', () => { + + it('fallback al código ISO si la moneda no está definida', () => { + const curr = createCurr(definitions, ling, {}, 'es'); + curr.setCurrency('XXX'); + expect(curr.f(99.5)).toBe('99,50 XXX'); + }); + + }); + + describe('convert()', () => { + + it('convierte EUR a USD', () => { + const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es'); + expect(curr.convert(100, 'EUR', 'USD')).toBeCloseTo(108, 1); + }); + + it('convierte EUR a MXN', () => { + const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es'); + expect(curr.convert(100, 'EUR', 'MXN')).toBeCloseTo(1850, 0); + }); + + it('misma moneda devuelve el mismo valor', () => { + const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es'); + expect(curr.convert(100, 'EUR', 'EUR')).toBe(100); + }); + + it('devuelve null si conversionValue es null', () => { + const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es'); + expect(curr.convert(100, 'EUR', 'JPY')).toBeNull(); + }); + + it('conversionValue como función dinámica', () => { + let rate = 1.08; + const defs = { + EUR: { ...definitions.EUR }, + USD: { ...definitions.USD, conversionValue: () => rate }, + }; + const curr = createCurr(defs, ling, { selectedCurrency: 'EUR' }, 'es'); + + expect(curr.convert(100, 'EUR', 'USD')).toBeCloseTo(108, 1); + + rate = 1.20; + expect(curr.convert(100, 'EUR', 'USD')).toBeCloseTo(120, 1); + }); + + }); + + describe('convertTo()', () => { + + it('convierte desde selectedCurrency a otra moneda', () => { + const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es'); + expect(curr.convertTo(100, 'USD')).toBeCloseTo(108, 1); + }); + + it('respeta la moneda activa cambiada con setCurrency', () => { + const curr = createCurr(definitions, ling, { selectedCurrency: 'EUR' }, 'es'); + curr.setCurrency('USD'); + expect(curr.convertTo(108, 'EUR')).toBeCloseTo(100, 1); + }); + + }); + + describe('get()', () => { + + it('devuelve la definición de una moneda registrada', () => { + const curr = createCurr(definitions, ling, {}, 'es'); + expect(curr.get('EUR')?.symbol).toBe('€'); + }); + + it('devuelve undefined para moneda no registrada', () => { + const curr = createCurr(definitions, ling, {}, 'es'); + expect(curr.get('XXX')).toBeUndefined(); + }); + + it('acepta minúsculas', () => { + const curr = createCurr(definitions, ling, {}, 'es'); + expect(curr.get('eur')).toBeDefined(); + }); + + }); + +}); diff --git a/src/glob/tests/dati.test.ts b/src/glob/tests/dati.test.ts new file mode 100644 index 0000000..cb8c1bc --- /dev/null +++ b/src/glob/tests/dati.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest'; +import { createDati } from '@/glob/dati_engine'; + +const DATE = new Date(2026, 1, 28, 10, 30, 0); // 28 feb 2026, 10:30:00 + +describe('createDati', () => { + + describe('locale reactivo via función', () => { + + it('responde a cambios de locale sin recrear el módulo', () => { + let locale = 'es'; + const dati = createDati({}, () => locale); + expect(dati.f(DATE)).toBe('28 de febrero de 2026'); + + locale = 'en'; + expect(dati.f(DATE)).toBe('February 28, 2026'); + }); + + it('locale por llamada tiene prioridad sobre defaultLocale', () => { + let locale = 'es'; + const dati = createDati({}, () => locale); + expect(dati.f(DATE, 'long', 'de')).toBe('28. Februar 2026'); + }); + + }); + + describe('f() — sin opciones (fecha larga por defecto)', () => { + + it('formatea fecha larga en es', () => { + const dati = createDati({}, 'es'); + expect(dati.f(DATE)).toBe('28 de febrero de 2026'); + }); + + it('formatea fecha larga en en', () => { + const dati = createDati({}, 'en'); + expect(dati.f(DATE)).toBe('February 28, 2026'); + }); + + it('formatea fecha larga en de', () => { + const dati = createDati({}, 'de'); + expect(dati.f(DATE)).toBe('28. Februar 2026'); + }); + + it('formatea fecha larga en fr', () => { + const dati = createDati({}, 'fr'); + expect(dati.f(DATE)).toBe('28 février 2026'); + }); + + }); + + describe('f() — opción string shortcut', () => { + + it('short — fecha corta en es', () => { + const dati = createDati({}, 'es'); + expect(dati.f(DATE, 'short')).toBe('28/2/26'); + }); + + it('short — fecha corta en en', () => { + const dati = createDati({}, 'en'); + expect(dati.f(DATE, 'short')).toBe('2/28/26'); + }); + + it('long equivale a sin opciones', () => { + const dati = createDati({}, 'es'); + expect(dati.f(DATE, 'long')).toBe(dati.f(DATE)); + }); + + }); + + describe('f() — solo fecha', () => { + + it('date long en es', () => { + const dati = createDati({}, 'es'); + expect(dati.f(DATE, { date: 'long' })).toBe('28 de febrero de 2026'); + }); + + it('date short en es', () => { + const dati = createDati({}, 'es'); + expect(dati.f(DATE, { date: 'short' })).toBe('28/2/26'); + }); + + }); + + describe('f() — solo hora', () => { + + it('time short 24h en es', () => { + const dati = createDati({ selectedTimeFormat: '24h' }, 'es'); + expect(dati.f(DATE, { time: 'short' })).toBe('10:30'); + }); + + it('time short 12h en en', () => { + const dati = createDati({ selectedTimeFormat: '12h' }, 'en'); + expect(dati.f(DATE, { time: 'short' })).toBe('10:30 AM'); + }); + + it('time long 24h en es', () => { + const dati = createDati({ selectedTimeFormat: '24h' }, 'es'); + expect(dati.f(DATE, { time: 'long' })).toContain('10:30:00'); + }); + + }); + + describe('f() — fecha y hora combinadas', () => { + + it('date long + time short en es', () => { + const dati = createDati({ selectedTimeFormat: '24h' }, 'es'); + const result = dati.f(DATE, { date: 'long', time: 'short' }); + expect(result).toContain('28 de febrero de 2026'); + expect(result).toContain('10:30'); + }); + + it('date short + time short en en 12h', () => { + const dati = createDati({ selectedTimeFormat: '12h' }, 'en'); + const result = dati.f(DATE, { date: 'short', time: 'short' }); + expect(result).toContain('2/28/26'); + expect(result).toContain('10:30 AM'); + }); + + }); + + describe('setTimeFormat / getTimeFormat', () => { + + it('cambia el formato de hora', () => { + const dati = createDati({ selectedTimeFormat: '12h' }, 'es'); + const result12h = dati.f(DATE, { time: 'short' }); + const result24h = new Intl.DateTimeFormat('es', { timeStyle: 'short', hour12: false }).format(DATE); + + // 12h y 24h deben ser distintos + expect(result12h).not.toBe(result24h); + + dati.setTimeFormat('24h'); + expect(dati.f(DATE, { time: 'short' })).toBe(result24h); + }); + + it('getTimeFormat devuelve el valor actual', () => { + const dati = createDati({ selectedTimeFormat: '12h' }, 'es'); + expect(dati.getTimeFormat()).toBe('12h'); + dati.setTimeFormat('24h'); + expect(dati.getTimeFormat()).toBe('24h'); + }); + + }); + + describe('setDateOrder / getDateOrder', () => { + + it('getDateOrder devuelve el valor configurado', () => { + const dati = createDati({ selectedDateOrder: 'MDY' }, 'es'); + expect(dati.getDateOrder()).toBe('MDY'); + }); + + it('setDateOrder cambia el orden', () => { + const dati = createDati({}, 'es'); + dati.setDateOrder('YMD'); + expect(dati.getDateOrder()).toBe('YMD'); + }); + + }); + +}); \ No newline at end of file diff --git a/src/glob/tests/glob.test.ts b/src/glob/tests/glob.test.ts new file mode 100644 index 0000000..39ff0dc --- /dev/null +++ b/src/glob/tests/glob.test.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createGlob } from '@/glob/glob_engine'; +import { createLing, p } from '@/ling/engine'; +import type { CurrDefinition } from '../curr_types'; + +// ============================================================================= +// FIXTURES +// ============================================================================= + +const schema = { + common: { + ok : { es: 'Aceptar', en: 'OK' }, + cancel : { es: 'Cancelar', en: 'Cancel' }, + }, + checkout: { + pay: { es: 'Pagar', en: 'Pay' }, + }, +} as const; + +const currDefinitions: Record = { + EUR: { + symbol : '€', + code : 'EUR', + name : p({ es: { one: 'euro', other: 'euros' }, en: { one: 'euro', other: 'euros' } }), + conversionValue: 1, + }, + USD: { + symbol : '$', + code : 'USD', + name : p({ es: { one: 'dólar', other: 'dólares' }, en: { one: 'dollar', other: 'dollars' } }), + conversionValue: 1.08, + }, +}; + +function makeGlob(locale = 'es') { + const ling = createLing(schema, locale as any); + return createGlob({ + locale, + ling, + numr : { minDecimals: 0, maxDecimals: 2 }, + curr : { definitions: currDefinitions, selectedCurrency: 'EUR' }, + dati : { selectedTimeFormat: '24h' }, + unit : { selectedSystem: 'metric' }, + }); +} + +const DATE = new Date(2026, 1, 28, 10, 30, 0); + +// ============================================================================= +// GLOB — Integración +// ============================================================================= + +describe('createGlob', () => { + + describe('locale', () => { + + it('getLocale devuelve el locale inicial', () => { + const glob = makeGlob('es'); + expect(glob.getLocale()).toBe('es'); + }); + + it('setLocale cambia el locale', () => { + const glob = makeGlob('es'); + glob.setLocale('en'); + expect(glob.getLocale()).toBe('en'); + }); + + it('setLocale sincroniza ling', () => { + const glob = makeGlob('es'); + expect(glob.t('common.ok')).toBe('Aceptar'); + + glob.setLocale('en'); + expect(glob.t('common.ok')).toBe('OK'); + }); + + it('setLocale sincroniza todos los módulos via LocaleResolver', () => { + const glob = makeGlob('es'); + + expect(glob.numr.f(1234.5)).toContain(',5'); // es — coma decimal + expect(glob.dati.f(DATE)).toContain('febrero'); + expect(glob.curr.f(99)).toContain(',00 €'); + + glob.setLocale('en'); + + expect(glob.numr.f(1234.5)).toContain('.5'); // en — punto decimal + expect(glob.dati.f(DATE)).toContain('February'); + expect(glob.curr.f(99)).toContain('.00 €'); + }); + + }); + + describe('onLocaleChange', () => { + + it('notifica a los listeners al cambiar locale', () => { + const glob = makeGlob('es'); + const fn = vi.fn(); + glob.onLocaleChange(fn); + + glob.setLocale('en'); + expect(fn).toHaveBeenCalledWith('en'); + }); + + it('devuelve una función de unsubscribe', () => { + const glob = makeGlob('es'); + const fn = vi.fn(); + const unsub = glob.onLocaleChange(fn); + + unsub(); + glob.setLocale('en'); + expect(fn).not.toHaveBeenCalled(); + }); + + it('notifica a múltiples listeners', () => { + const glob = makeGlob('es'); + const fn1 = vi.fn(); + const fn2 = vi.fn(); + glob.onLocaleChange(fn1); + glob.onLocaleChange(fn2); + + glob.setLocale('en'); + expect(fn1).toHaveBeenCalledWith('en'); + expect(fn2).toHaveBeenCalledWith('en'); + }); + + }); + + describe('t() — acceso directo a ling', () => { + + it('traduce correctamente en es', () => { + const glob = makeGlob('es'); + expect(glob.t('common.ok')).toBe('Aceptar'); + }); + + it('traduce correctamente tras cambio de locale', () => { + const glob = makeGlob('es'); + glob.setLocale('en'); + expect(glob.t('common.ok')).toBe('OK'); + }); + + }); + + describe('numr — integrado con glob', () => { + + it('formatea en el locale de glob', () => { + const glob = makeGlob('es'); + expect(glob.numr.f(1234.5)).toContain(',5'); + }); + + it('responde al cambio de locale de glob', () => { + const glob = makeGlob('es'); + glob.setLocale('en'); + expect(glob.numr.f(1234.5)).toContain('.5'); + }); + + }); + + describe('curr — integrado con glob', () => { + + it('formatea en el locale de glob', () => { + const glob = makeGlob('es'); + expect(glob.curr.f(99)).toContain(',00 €'); + }); + + it('responde al cambio de locale de glob', () => { + const glob = makeGlob('es'); + glob.setLocale('en'); + expect(glob.curr.f(99)).toContain('.00 €'); + }); + + it('setCurrency es independiente del locale', () => { + const glob = makeGlob('es'); + glob.curr.setCurrency('USD'); + expect(glob.curr.f(99)).toContain('$'); + expect(glob.getLocale()).toBe('es'); // locale no cambia + }); + + it('convert funciona tras cambio de locale', () => { + const glob = makeGlob('es'); + glob.setLocale('en'); + expect(glob.curr.convert(100, 'EUR', 'USD')).toBeCloseTo(108, 1); + }); + + }); + + describe('dati — integrado con glob', () => { + + it('formatea en el locale de glob', () => { + const glob = makeGlob('es'); + expect(glob.dati.f(DATE)).toContain('febrero'); + }); + + it('responde al cambio de locale de glob', () => { + const glob = makeGlob('es'); + glob.setLocale('en'); + expect(glob.dati.f(DATE)).toContain('February'); + }); + + it('setTimeFormat es independiente del locale', () => { + const glob = makeGlob('es'); + glob.dati.setTimeFormat('12h'); + expect(glob.dati.getTimeFormat()).toBe('12h'); + expect(glob.getLocale()).toBe('es'); + }); + + }); + + describe('unit — integrado con glob', () => { + + it('formatea en el locale de glob', () => { + const glob = makeGlob('es'); + expect(glob.unit.weight.f(1, 'name')).toBe('1 kilogramo'); + }); + + it('responde al cambio de locale de glob', () => { + const glob = makeGlob('es'); + glob.setLocale('en'); + expect(glob.unit.weight.f(1, 'name')).toBe('1 kilogram'); + }); + + it('setSystem es independiente del locale', () => { + const glob = makeGlob('es'); + glob.unit.setSystem('imperial'); + expect(glob.unit.getSystem()).toBe('imperial'); + expect(glob.getLocale()).toBe('es'); + }); + + it('setSystem + setLocale son ortogonales', () => { + const glob = makeGlob('es'); + glob.unit.setSystem('imperial'); + glob.setLocale('en'); + + // sistema imperial, texto en inglés + expect(glob.unit.weight.f(1, 'name')).toBe('1 pound'); + }); + + }); + +}); diff --git a/src/glob/tests/numr.test.ts b/src/glob/tests/numr.test.ts new file mode 100644 index 0000000..99d0eae --- /dev/null +++ b/src/glob/tests/numr.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest'; +import { createNumr } from '../numr_engine'; + +// ============================================================================= +// NUMR — Formato de números +// ============================================================================= + +describe('createNumr', () => { + + describe('defaultLocale', () => { + + it('acepta una función como defaultLocale', () => { + let locale = 'es'; + const numr = createNumr({}, () => locale); + expect(numr.f(1234.5)).toBe('1234,5'); + + locale = 'en'; + expect(numr.f(1234.5)).toBe('1,234.5'); + }); + + it('acepta un string como defaultLocale', () => { + const numr = createNumr({}, 'en'); + expect(numr.f(1234.5)).toBe('1,234.5'); + }); + + }); + + describe('f() — formato básico', () => { + + it('formatea enteros en es', () => { + const numr = createNumr({}, 'es'); + expect(numr.f(1000000)).toBe('1.000.000'); + }); + + it('formatea enteros en en', () => { + const numr = createNumr({}, 'en'); + expect(numr.f(1000000)).toBe('1,000,000'); + }); + + it('formatea decimales en es', () => { + const numr = createNumr({}, 'es'); + expect(numr.f(1234.56)).toBe('1234,56'); + }); + + it('formatea decimales en en', () => { + const numr = createNumr({}, 'en'); + expect(numr.f(1234.56)).toBe('1,234.56'); + }); + + it('formatea en de (alemán)', () => { + const numr = createNumr({}, 'de'); + expect(numr.f(1234.56)).toBe('1.234,56'); + }); + + it('formatea cero', () => { + const numr = createNumr({}, 'es'); + expect(numr.f(0)).toBe('0'); + }); + + it('formatea números negativos', () => { + const numr = createNumr({}, 'es'); + expect(numr.f(-1234.5)).toBe('-1234,5'); + }); + + }); + + describe('f() — opciones de decimales', () => { + + it('respeta minDecimals del constructor', () => { + const numr = createNumr({ minDecimals: 2 }, 'en'); + expect(numr.f(1)).toBe('1.00'); + expect(numr.f(1.5)).toBe('1.50'); + }); + + it('respeta maxDecimals del constructor', () => { + const numr = createNumr({ maxDecimals: 0 }, 'en'); + expect(numr.f(1234.56)).toBe('1,235'); + }); + + it('admite opciones por llamada que sobreescriben el constructor', () => { + const numr = createNumr({ maxDecimals: 2 }, 'en'); + expect(numr.f(1.5, { minDecimals: 2 })).toBe('1.50'); + expect(numr.f(1234.5, { maxDecimals: 0 })).toBe('1,235'); + }); + + it('admite locale por llamada que sobreescribe el defaultLocale', () => { + const numr = createNumr({}, 'es'); + expect(numr.f(1234.5, {}, 'en')).toBe('1,234.5'); + }); + + it('minDecimals 0 y maxDecimals 0 — sin decimales', () => { + const numr = createNumr({ minDecimals: 0, maxDecimals: 0 }, 'en'); + expect(numr.f(1234.9)).toBe('1,235'); + }); + + it('minDecimals 4 — fuerza 4 decimales', () => { + const numr = createNumr({ minDecimals: 4, maxDecimals: 4 }, 'en'); + expect(numr.f(1.5)).toBe('1.5000'); + }); + + }); + + describe('locale reactivo via función', () => { + + it('responde a cambios de locale sin recrear el módulo', () => { + let locale = 'es'; + const numr = createNumr({}, () => locale); + + expect(numr.f(1234.56)).toContain(',56'); + + locale = 'en'; + expect(numr.f(1234.56)).toContain('.56'); + }); + + it('locale por llamada tiene prioridad sobre defaultLocale', () => { + let locale = 'es'; + const numr = createNumr({}, () => locale); + expect(numr.f(1234.5, {}, 'de')).toBe('1.234,5'); + }); + + }); + + describe('locales extendidos', () => { + + it('acepta locales extendidos como es-MX', () => { + const numr = createNumr({}, 'es-MX'); + expect(() => numr.f(1234.5)).not.toThrow(); + }); + + it('formatea en ja (japonés)', () => { + const numr = createNumr({}, 'ja'); + expect(numr.f(1234)).toBe('1,234'); + }); + + }); + +}); diff --git a/src/glob/tests/unit.test.ts b/src/glob/tests/unit.test.ts new file mode 100644 index 0000000..5610da0 --- /dev/null +++ b/src/glob/tests/unit.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect } from 'vitest'; +import { createUnit } from '../unit_engine'; + +describe('createUnit', () => { + + describe('selectedSystem', () => { + + it('usa metric por defecto', () => { + const unit = createUnit({}, {}, 'es'); + expect(unit.getSystem()).toBe('metric'); + }); + + it('acepta imperial en la configuración', () => { + const unit = createUnit({}, { selectedSystem: 'imperial' }, 'es'); + expect(unit.getSystem()).toBe('imperial'); + }); + + it('setSystem cambia el sistema activo', () => { + const unit = createUnit({}, {}, 'es'); + unit.setSystem('imperial'); + expect(unit.getSystem()).toBe('imperial'); + }); + + it('el sistema reactivo afecta a f()', () => { + const unit = createUnit({}, { selectedSystem: 'metric' }, 'es'); + expect(unit.weight.f(33)).toContain('kg'); + + unit.setSystem('imperial'); + expect(unit.weight.f(33)).toContain('lb'); + }); + + }); + + describe('locale reactivo via función', () => { + + it('responde a cambios de locale sin recrear el módulo', () => { + let locale = 'es'; + const unit = createUnit({}, { selectedSystem: 'metric' }, () => locale); + expect(unit.weight.f(1, 'name')).toBe('1 kilogramo'); + + locale = 'en'; + expect(unit.weight.f(1, 'name')).toBe('1 kilogram'); + }); + + }); + + // ========================================================================= + // WEIGHT + // ========================================================================= + + describe('weight', () => { + + it('f() metric — unidad base kg', () => { + const unit = createUnit({}, { selectedSystem: 'metric' }, 'es'); + expect(unit.weight.f(33)).toContain('kg'); + }); + + it('f() imperial — unidad base lb', () => { + const unit = createUnit({}, { selectedSystem: 'imperial' }, 'es'); + expect(unit.weight.f(33)).toContain('lb'); + }); + + it('f() display name singular en es', () => { + const unit = createUnit({}, { selectedSystem: 'metric' }, 'es'); + expect(unit.weight.f(1, 'name')).toBe('1 kilogramo'); + }); + + it('f() display name plural en es', () => { + const unit = createUnit({}, { selectedSystem: 'metric' }, 'es'); + expect(unit.weight.f(5, 'name')).toBe('5 kilogramos'); + }); + + it('f() display name en en', () => { + const unit = createUnit({}, { selectedSystem: 'metric' }, 'en'); + expect(unit.weight.f(1, 'name')).toBe('1 kilogram'); + }); + + it('f() display name imperial en en', () => { + const unit = createUnit({}, { selectedSystem: 'imperial' }, 'en'); + expect(unit.weight.f(1, 'name')).toBe('1 pound'); + }); + + it('fTo() convierte kg a lb', () => { + const unit = createUnit({}, { selectedSystem: 'imperial' }, 'en'); + const result = unit.weight.fTo(1); + expect(result).toContain('lb'); + expect(result).toContain('2.2'); + }); + + it('fTo() con unidad destino explícita', () => { + const unit = createUnit({}, { selectedSystem: 'metric' }, 'es'); + expect(unit.weight.fTo(1, 'oz')).toContain('oz'); + }); + + it('fTo() sin conversión en metric devuelve kg', () => { + const unit = createUnit({}, { selectedSystem: 'metric' }, 'es'); + expect(unit.weight.fTo(33)).toContain('kg'); + }); + + it('convert() kg a lb', () => { + const unit = createUnit(); + expect(unit.weight.convert(1, 'kg', 'lb')).toBeCloseTo(2.2046, 2); + }); + + it('convert() kg a g', () => { + const unit = createUnit(); + expect(unit.weight.convert(1, 'kg', 'g')).toBeCloseTo(1000, 0); + }); + + it('convert() misma unidad', () => { + const unit = createUnit(); + expect(unit.weight.convert(33, 'kg', 'kg')).toBe(33); + }); + + }); + + // ========================================================================= + // LENGTH + // ========================================================================= + + describe('length', () => { + + it('f() metric — unidad base m', () => { + const unit = createUnit({}, { selectedSystem: 'metric' }, 'es'); + expect(unit.length.f(100)).toContain('m'); + }); + + it('f() imperial — unidad base mi', () => { + const unit = createUnit({}, { selectedSystem: 'imperial' }, 'es'); + expect(unit.length.f(1609)).toContain('mi'); + }); + + it('convert() m a km', () => { + const unit = createUnit(); + expect(unit.length.convert(1000, 'm', 'km')).toBeCloseTo(1, 5); + }); + + it('convert() km a mi', () => { + const unit = createUnit(); + expect(unit.length.convert(1.609344, 'km', 'mi')).toBeCloseTo(1, 3); + }); + + }); + + // ========================================================================= + // TEMP + // ========================================================================= + + describe('temp', () => { + + it('f() metric — °C', () => { + const unit = createUnit({}, { selectedSystem: 'metric' }, 'es'); + expect(unit.temp.f(100)).toContain('°C'); + }); + + it('f() imperial — °F', () => { + const unit = createUnit({}, { selectedSystem: 'imperial' }, 'es'); + expect(unit.temp.f(212)).toContain('°F'); + }); + + it('fTo() convierte °C a °F', () => { + const unit = createUnit({}, { selectedSystem: 'imperial' }, 'es'); + const result = unit.temp.fTo(100); + expect(result).toContain('°F'); + expect(result).toContain('212'); + }); + + it('convert() °C a °F', () => { + const unit = createUnit(); + expect(unit.temp.convert(100, 'c', 'f')).toBeCloseTo(212, 0); + }); + + it('convert() °C a K', () => { + const unit = createUnit(); + expect(unit.temp.convert(0, 'c', 'k')).toBeCloseTo(273.15, 1); + }); + + it('convert() °F a °C', () => { + const unit = createUnit(); + expect(unit.temp.convert(32, 'f', 'c')).toBeCloseTo(0, 1); + }); + + }); + + // ========================================================================= + // VOLUME + // ========================================================================= + + describe('volume', () => { + + it('f() metric — l', () => { + const unit = createUnit({}, { selectedSystem: 'metric' }, 'es'); + expect(unit.volume.f(2)).toContain('l'); + }); + + it('f() imperial — gal', () => { + const unit = createUnit({}, { selectedSystem: 'imperial' }, 'es'); + expect(unit.volume.f(2)).toContain('gal'); + }); + + it('convert() l a ml', () => { + const unit = createUnit(); + expect(unit.volume.convert(1, 'l', 'ml')).toBeCloseTo(1000, 0); + }); + + it('convert() l a gal', () => { + const unit = createUnit(); + expect(unit.volume.convert(3.78541, 'l', 'gal')).toBeCloseTo(1, 2); + }); + + }); + + // ========================================================================= + // AREA + // ========================================================================= + + describe('area', () => { + + it('f() metric — m²', () => { + const unit = createUnit({}, { selectedSystem: 'metric' }, 'es'); + expect(unit.area.f(100)).toContain('m²'); + }); + + it('f() imperial — ft²', () => { + const unit = createUnit({}, { selectedSystem: 'imperial' }, 'es'); + expect(unit.area.f(100)).toContain('ft²'); + }); + + it('convert() m² a ft²', () => { + const unit = createUnit(); + expect(unit.area.convert(1, 'm2', 'ft2')).toBeCloseTo(10.7639, 2); + }); + + it('convert() ha a m²', () => { + const unit = createUnit(); + expect(unit.area.convert(1, 'ha', 'm2')).toBeCloseTo(10000, 0); + }); + + }); + +}); diff --git a/src/glob/unit_data.ts b/src/glob/unit_data.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/glob/unit_engine.ts b/src/glob/unit_engine.ts new file mode 100644 index 0000000..ad8a25c --- /dev/null +++ b/src/glob/unit_engine.ts @@ -0,0 +1,152 @@ + +import type { LocaleResolver, GlobLocale } from "./comn_types"; +import type { UnitCategory, UnitCategoryInstance, UnitCategoryKey, UnitDisplay, UnitInstance, UnitSystem } from "./unit_types"; +import { resolveLocale } from "./comn_engine"; +import { defaultCategories } from "./unit_langs"; +import { defaultISOLocale } from "@/ling/consts"; + + +// ============================== +// CONVERSIÓN DE TEMPERATURA +// ============================== + +/** + * Convierte temperatura entre Celsius, Fahrenheit y Kelvin. + * Usa Celsius como pivote intermedio para todas las conversiones. + */ +function convertTemp(value: number, from: string, to: string): number { + if (from === to) return value; + let celsius: number; + switch (from) { + case 'c': celsius = value; break; + case 'f': celsius = (value - 32) * 5 / 9; break; + case 'k': celsius = value - 273.15; break; + default: celsius = value; + } + switch (to) { + case 'c': return celsius; + case 'f': return celsius * 9 / 5 + 32; + case 'k': return celsius + 273.15; + default: return celsius; + } +} + +// ============================== +// CATEGORY ENGINE +// ============================== + +/** + * Crea las funciones de formato y conversión para una categoría de unidades. + * El sistema activo se lee en cada llamada via `getSystem()`, por lo que + * responde a `setSystem()` sin necesidad de recrear la categoría. + */ +function createCategory( + categoryKey : UnitCategoryKey, + category : UnitCategory, + getSystem : () => UnitSystem, + defaultLocale: LocaleResolver, +): UnitCategoryInstance { + + /** Devuelve la unidad base según el sistema activo en este momento. */ + function resolveTargetUnit(): string { + return getSystem() === 'imperial' ? category.baseImperial : category.base; + } + + /** Formatea `value` en la unidad `unitKey` con símbolo o nombre localizado. */ + function formatValue(value: number, unitKey: string, display: UnitDisplay, locale: GlobLocale): string { + const def = category.units[unitKey]; + if (!def) return `${value} ${unitKey}`; + + const formatted = new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value); + // Extrae solo el código de idioma (sin región) para buscar en symbol/name. + const baseLocale = locale.split('-')[0] as keyof typeof def.symbol; + + if (display === 'symbol') { + const symbol = def.symbol[baseLocale] ?? def.symbol['es']; + return `${formatted} ${symbol}`; + } + + const record = def.name({ count: value }); + const name = record[baseLocale as keyof typeof record] ?? record['es']; + return `${formatted} ${name}`; + } + + /** + * Conversión numérica entre dos unidades de la categoría. + * Para temperatura delega en `convertTemp` (conversión no lineal). + * Para el resto: `resultado = (valor × factorOrigen) / factorDestino` + */ + function convert(value: number, from: string, to: string): number { + if (from === to) return value; + if (categoryKey === 'temp') return convertTemp(value, from, to); + const fromDef = category.units[from]; + const toDef = category.units[to]; + if (!fromDef || !toDef) return value; + return (value * fromDef.factor) / toDef.factor; + } + + /** Formatea `value` en la unidad base del sistema activo. */ + function f(value: number, display: UnitDisplay = 'symbol', locale: LocaleResolver = defaultLocale): string { + const loc = resolveLocale(locale); + const unit = resolveTargetUnit(); + return formatValue(value, unit, display, loc); + } + + /** + * Convierte `value` desde la base métrica de la categoría hacia `targetUnit` + * y lo formatea. Si se omite `targetUnit`, usa la base del sistema activo. + */ + function fTo(value: number, targetUnit?: string, display: UnitDisplay = 'symbol', locale: LocaleResolver = defaultLocale): string { + const loc = resolveLocale(locale); + const to = targetUnit ?? resolveTargetUnit(); + const converted = convert(value, category.base, to); + return formatValue(converted, to, display, loc); + } + + return { f, fTo, convert }; +} + +// ============================== +// UNIT ENGINE +// ============================== + +/** + * Crea un módulo de unidades de medida con conversión y formateo. + * + * Las categorías pasadas en `categories` sobreescriben las de `defaultCategories`, + * lo que permite reemplazar categorías completas sin tocar el resto. + * + * @param categories - Categorías personalizadas (parcial). + * @param opts - Sistema activo inicial. + * @param defaultLocale - Locale o resolver para todas las categorías. + */ +export function createUnit( + categories : Partial> = {}, + opts : { selectedSystem?: UnitSystem } = {}, + defaultLocale: LocaleResolver = defaultISOLocale +): UnitInstance { + + let currentSystem: UnitSystem = opts.selectedSystem ?? 'metric'; + + function getSystem(): UnitSystem { return currentSystem; } + function setSystem(s: UnitSystem) { currentSystem = s; } + + // Las categorías personalizadas sobreescriben las definiciones por defecto. + const merged: Record = { + ...defaultCategories, + ...categories, + }; + + const makeCategory = (key: UnitCategoryKey) => + createCategory(key, merged[key], getSystem, defaultLocale); + + return { + getSystem, + setSystem, + weight : makeCategory('weight'), + length : makeCategory('length'), + temp : makeCategory('temp'), + volume : makeCategory('volume'), + area : makeCategory('area'), + }; +} diff --git a/src/glob/unit_langs.ts b/src/glob/unit_langs.ts new file mode 100644 index 0000000..0fc5724 --- /dev/null +++ b/src/glob/unit_langs.ts @@ -0,0 +1,50 @@ +import { p } from '@/ling/engine'; +import type { LingNode } from '@/ling/types'; + +/** + * Traducciones de unidades de medida. + * Se registra en ling bajo el nodo 'units' via glob_engine. + * Los datos numéricos (factor, system, base) viven en units_data.ts. + */ +export const unitsSchema = { + weight: { + kg: { symbol: { es: 'kg', en: 'kg' }, name: p({ es: { one: 'kilogramo', other: 'kilogramos' }, en: { one: 'kilogram', other: 'kilograms' } }) }, + g: { symbol: { es: 'g', en: 'g' }, name: p({ es: { one: 'gramo', other: 'gramos' }, en: { one: 'gram', other: 'grams' } }) }, + lb: { symbol: { es: 'lb', en: 'lb' }, name: p({ es: { one: 'libra', other: 'libras' }, en: { one: 'pound', other: 'pounds' } }) }, + oz: { symbol: { es: 'oz', en: 'oz' }, name: p({ es: { one: 'onza', other: 'onzas' }, en: { one: 'ounce', other: 'ounces' } }) }, + t: { symbol: { es: 't', en: 't' }, name: p({ es: { one: 'tonelada', other: 'toneladas' }, en: { one: 'tonne', other: 'tonnes' } }) }, + }, + length: { + km: { symbol: { es: 'km', en: 'km' }, name: p({ es: { one: 'kilómetro', other: 'kilómetros' }, en: { one: 'kilometer', other: 'kilometers' } }) }, + m: { symbol: { es: 'm', en: 'm' }, name: p({ es: { one: 'metro', other: 'metros' }, en: { one: 'meter', other: 'meters' } }) }, + cm: { symbol: { es: 'cm', en: 'cm' }, name: p({ es: { one: 'centímetro', other: 'centímetros' }, en: { one: 'centimeter', other: 'centimeters' } }) }, + mm: { symbol: { es: 'mm', en: 'mm' }, name: p({ es: { one: 'milímetro', other: 'milímetros' }, en: { one: 'millimeter', other: 'millimeters' } }) }, + mi: { symbol: { es: 'mi', en: 'mi' }, name: p({ es: { one: 'milla', other: 'millas' }, en: { one: 'mile', other: 'miles' } }) }, + ft: { symbol: { es: 'ft', en: 'ft' }, name: p({ es: { one: 'pie', other: 'pies' }, en: { one: 'foot', other: 'feet' } }) }, + in: { symbol: { es: 'in', en: 'in' }, name: p({ es: { one: 'pulgada', other: 'pulgadas' }, en: { one: 'inch', other: 'inches' } }) }, + yd: { symbol: { es: 'yd', en: 'yd' }, name: p({ es: { one: 'yarda', other: 'yardas' }, en: { one: 'yard', other: 'yards' } }) }, + }, + temp: { + c: { symbol: { es: '°C', en: '°C' }, name: p({ es: { one: 'grado Celsius', other: 'grados Celsius' }, en: { one: 'degree Celsius', other: 'degrees Celsius' } }) }, + f: { symbol: { es: '°F', en: '°F' }, name: p({ es: { one: 'grado Fahrenheit', other: 'grados Fahrenheit' }, en: { one: 'degree Fahrenheit', other: 'degrees Fahrenheit' } }) }, + k: { symbol: { es: 'K', en: 'K' }, name: p({ es: { one: 'kelvin', other: 'kelvins' }, en: { one: 'kelvin', other: 'kelvins' } }) }, + }, + volume: { + l: { symbol: { es: 'l', en: 'l' }, name: p({ es: { one: 'litro', other: 'litros' }, en: { one: 'liter', other: 'liters' } }) }, + ml: { symbol: { es: 'ml', en: 'ml' }, name: p({ es: { one: 'mililitro', other: 'mililitros' }, en: { one: 'milliliter', other: 'milliliters' } }) }, + m3: { symbol: { es: 'm³', en: 'm³' }, name: p({ es: { one: 'metro cúbico', other: 'metros cúbicos'}, en: { one: 'cubic meter', other: 'cubic meters' } }) }, + gal: { symbol: { es: 'gal', en: 'gal' }, name: p({ es: { one: 'galón', other: 'galones' }, en: { one: 'gallon', other: 'gallons' } }) }, + floz: { symbol: { es: 'fl oz', en: 'fl oz' }, name: p({ es: { one: 'onza líquida', other: 'onzas líquidas'}, en: { one: 'fluid ounce', other: 'fluid ounces' } }) }, + pt: { symbol: { es: 'pt', en: 'pt' }, name: p({ es: { one: 'pinta', other: 'pintas' }, en: { one: 'pint', other: 'pints' } }) }, + }, + area: { + m2: { symbol: { es: 'm²', en: 'm²' }, name: p({ es: { one: 'metro cuadrado', other: 'metros cuadrados' }, en: { one: 'square meter', other: 'square meters' } }) }, + km2: { symbol: { es: 'km²', en: 'km²' }, name: p({ es: { one: 'kilómetro cuadrado', other: 'kilómetros cuadrados' }, en: { one: 'square kilometer', other: 'square kilometers' } }) }, + ha: { symbol: { es: 'ha', en: 'ha' }, name: p({ es: { one: 'hectárea', other: 'hectáreas' }, en: { one: 'hectare', other: 'hectares' } }) }, + ft2: { symbol: { es: 'ft²', en: 'ft²' }, name: p({ es: { one: 'pie cuadrado', other: 'pies cuadrados' }, en: { one: 'square foot', other: 'square feet' } }) }, + mi2: { symbol: { es: 'mi²', en: 'mi²' }, name: p({ es: { one: 'milla cuadrada', other: 'millas cuadradas' }, en: { one: 'square mile', other: 'square miles' } }) }, + ac: { symbol: { es: 'ac', en: 'ac' }, name: p({ es: { one: 'acre', other: 'acres' }, en: { one: 'acre', other: 'acres' } }) }, + }, +} satisfies LingNode; + +export type UnitsSchema = typeof unitsSchema; \ No newline at end of file diff --git a/src/glob/unit_types.ts b/src/glob/unit_types.ts new file mode 100644 index 0000000..6b503b1 --- /dev/null +++ b/src/glob/unit_types.ts @@ -0,0 +1,70 @@ +// ============================== +// UNIT TYPES +// ============================== + +import type { LingPluralFn, LingRecord } from "../ling"; +import type { LocaleResolver } from "./comn_types"; + +/** Cómo se muestra la unidad: símbolo abreviado o nombre completo. */ +export type UnitDisplay = 'symbol' | 'name'; +/** Sistema de medida activo. Determina la unidad base usada en `f()`. */ +export type UnitSystem = 'metric' | 'imperial'; + +/** Definición de una unidad dentro de una categoría. */ +export interface UnitDefinition { + /** Símbolo localizado, ej. `{ es: 'kg', en: 'kg' }`. */ + symbol : LingRecord; + /** Función plural bilingüe para el nombre de la unidad. */ + name : LingPluralFn>; + /** + * Factor de conversión respecto a la base de la categoría. + * Fórmula: `resultado = (valor × factorOrigen) / factorDestino` + * Excepción: `temp` no usa factores (conversión no lineal). + */ + factor : number; + system : UnitSystem; +} + + +/** Configuración de `createUnit`. */ +export interface UnitConfig { + /** Sistema activo al iniciar. Default: `'metric'`. */ + selectedSystem?: UnitSystem; + /** Categorías personalizadas que sobreescriben las de `defaultCategories`. */ + categories?: Partial>; +} + + +/** Definición de una categoría de unidades (peso, longitud, etc.). */ +export interface UnitCategory { + /** Unidad base del sistema métrico (ej. `'kg'`, `'m'`). */ + base : string; + /** Unidad base del sistema imperial (ej. `'lb'`, `'mi'`). */ + baseImperial : string; + units : Record; +} + +/** Categorías de unidades disponibles. */ +export type UnitCategoryKey = 'weight' | 'length' | 'temp' | 'volume' | 'area'; + +export interface UnitCategoryInstance { + /** Formatea `value` en la unidad base del sistema activo. */ + f : (value: number, display?: UnitDisplay, locale?: LocaleResolver) => string; + /** + * Convierte `value` desde la base métrica hacia `targetUnit` y lo formatea. + * Si se omite `targetUnit`, usa la base del sistema activo. + */ + fTo : (value: number, targetUnit?: string, display?: UnitDisplay, locale?: LocaleResolver) => string; + /** Conversión numérica pura entre dos unidades de la categoría. Sin formateo. */ + convert: (value: number, from: string, to: string) => number; +} + +export interface UnitInstance { + weight : UnitCategoryInstance; + length : UnitCategoryInstance; + temp : UnitCategoryInstance; + volume : UnitCategoryInstance; + area : UnitCategoryInstance; + setSystem: (system: UnitSystem) => void; + getSystem: () => UnitSystem; +} diff --git a/src/ling/engine.ts b/src/ling/engine.ts index f376443..774ef1a 100644 --- a/src/ling/engine.ts +++ b/src/ling/engine.ts @@ -140,10 +140,6 @@ export function createLing( // t() solo orquesta: busca → resuelve → traduce. const finalValue = resolveValue(rawValue, args, 0); - if (typeof finalValue === 'function') { - return tsRecord(finalValue(args[0]), path, args[0]); - } - if (isLingRecord(finalValue)) { return tsRecord(finalValue, path, args[0]); } @@ -242,6 +238,6 @@ export const p = (config: PluralConfig) => // Logger por defecto — console puro, sin dependencias externas. // Se reemplaza con setLogger() una vez logr está inicializado. const consoleLogger: LingLogger = { - warn : (category, message) => isDev() && console.warn (message), - error: (category, message) => isDev() && console.error(message), + warn : (_category, message) => isDev() && console.warn (message), + error: (_category, message) => isDev() && console.error(message), }; \ No newline at end of file diff --git a/src/logr/docs/logr.md b/src/logr/docs/logr.md index 75e16a1..0d8a72e 100644 --- a/src/logr/docs/logr.md +++ b/src/logr/docs/logr.md @@ -57,7 +57,7 @@ if (import.meta.env.DEV) { Los cuatro métodos de log comparten la misma firma: ```ts -logr.debug(category, message, context?) +logr.debug(category, message, context?claude) logr.info (category, message, context?) logr.warn (category, message, context?) logr.error(category, message, context?) diff --git a/src/logr/tests/logr.test.ts b/src/logr/tests/logr.test.ts index 7933314..070b6b6 100644 --- a/src/logr/tests/logr.test.ts +++ b/src/logr/tests/logr.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createLogr, LogLevel } from '@/logr'; import { createLing } from '@/ling'; -import type { TranslationNode, SupportedLocale } from '@/ling'; +import type { LingNode, SupportedLocale } from '@/ling'; @@ -17,7 +17,7 @@ const schema = { db: { connectionError: { es: 'Error de conexión', en: 'Connection error' }, }, -} satisfies TranslationNode; +} satisfies LingNode; function makeI18n(locale: SupportedLocale = 'es') { const i18n = createLing(schema, 'es'); diff --git a/src/logr/tests/logr_transports.test.ts b/src/logr/tests/logr_transports.test.ts index 0c693b1..70edf78 100644 --- a/src/logr/tests/logr_transports.test.ts +++ b/src/logr/tests/logr_transports.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import {callbackTransport, consoleTransport, createLogr, httpTransport, type LogEntry, LogLevel} from '@/logr'; import { createLing } from '@/ling'; -import type { TranslationNode, SupportedLocale } from '@/ling'; +import type { LingNode, SupportedLocale } from '@/ling'; // ============================================================================ // HELPERS @@ -11,7 +11,7 @@ const schema = { auth: { loginFailed: { es: 'Login fallido', en: 'Login failed' }, } -} satisfies TranslationNode; +} satisfies LingNode; function makeI18n(locale: SupportedLocale = 'es') { const i18n = createLing(schema, 'es'); diff --git a/src/logr/transports.ts b/src/logr/transports.ts index caf5baf..48ef409 100644 --- a/src/logr/transports.ts +++ b/src/logr/transports.ts @@ -1,4 +1,5 @@ -import type { Transport, ConsoleTransportOptions, HttpTransportOptions, LogEntry, LogLevel } from './types.ts'; +import type { Transport, ConsoleTransportOptions, HttpTransportOptions, LogEntry } from './types.ts'; +import { LogLevel } from './types.ts'; // ============================================================================ // CONSOLE TRANSPORT @@ -34,10 +35,10 @@ export function consoleTransport(options: ConsoleTransportOptions = {}): Transpo const ctx = entry.context ?? ''; switch (entry.level) { - case 0: console.debug(...parts, ctx); break; // DEBUG - case 1: console.info (...parts, ctx); break; // INFO - case 2: console.warn (...parts, ctx); break; // WARN - case 3: console.error(...parts, ctx); break; // ERROR + case LogLevel.DEBUG: console.debug(...parts, ctx); break; + case LogLevel.INFO: console.info (...parts, ctx); break; + case LogLevel.WARN: console.warn (...parts, ctx); break; + case LogLevel.ERROR: console.error(...parts, ctx); break; } } };