Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>master
parent
3aaf62c571
commit
8f422e2b4f
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.idea/
|
||||
package-lock.json
|
||||
@ -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
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
@ -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<S extends LingNode>(
|
||||
definitions : Record<string, CurrDefinition>,
|
||||
ling : LingInstance<S>,
|
||||
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 };
|
||||
}
|
||||
@ -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<Record<never, never>>;
|
||||
/** 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<string, CurrDefinition>;
|
||||
/** 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;
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<S extends LingNode>(config: GlobConfig<S>): GlobInstance<S> {
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -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<S extends LingNode> {
|
||||
locale : GlobLocale;
|
||||
/** Instancia de `ling` — es la fuente de verdad del locale compartido. */
|
||||
ling : LingInstance<S>;
|
||||
numr? : NumrConfig;
|
||||
curr? : CurrConfig;
|
||||
dati? : DatiConfig;
|
||||
unit? : UnitConfig;
|
||||
}
|
||||
|
||||
|
||||
/** Instancia devuelta por `createGlob`. Punto de entrada unificado de globalización. */
|
||||
export interface GlobInstance<S extends LingNode = LingNode> {
|
||||
/** 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<S>['t'];
|
||||
/** Traducción con plural, delegada a `ling.ts`. */
|
||||
ts : LingInstance<S>['ts'];
|
||||
numr : NumrInstance;
|
||||
curr : CurrInstance;
|
||||
dati : DatiInstance;
|
||||
unit : UnitInstance;
|
||||
/** Instancia `ling` subyacente, accesible para uso directo. */
|
||||
ling : LingInstance<S>;
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<string, CurrDefinition> = {
|
||||
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();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@ -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<string, CurrDefinition> = {
|
||||
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');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@ -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;
|
||||
Loading…
Reference in new issue