From 70655bbffc4741f52bf6ee84b765c4a18043f9a2 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 24 Feb 2026 03:31:49 +0100 Subject: [PATCH] First Commit --- docs/logr.md | 163 ++++++-- .../{z_i18n.test.ts => tests/i18n.test.ts} | 4 +- .../{z_logr.test.ts => tests/logr.test.ts} | 2 +- .../logr_transports.test.ts} | 11 +- src/libs/olds/i18n/i18n.test.ts | 85 ---- src/libs/olds/i18n/i18n.ts | 78 ---- src/libs/olds/i18n/i18n.types.ts | 37 -- src/libs/olds/i18n/index.ts | 39 -- src/libs/olds/i18n/schema.ts | 25 -- src/libs/olds/itn/i18n.ts | 50 --- src/libs/olds/itn/i18n.types.ts | 81 ---- src/libs/olds/itn/schema.ts | 25 -- src/libs/vice/consts/messages.ts | 182 ++++++++ src/libs/vice/engines/evaluator.engine.ts | 183 ++++++++ src/libs/vice/engines/rule.engine.ts | 298 +++++++++++++ .../vice/engines/template-resolver.engine.ts | 290 +++++++++++++ src/libs/vice/tests/evaluator.test.ts | 391 ++++++++++++++++++ src/libs/vice/types/api.types.ts | 29 ++ src/libs/vice/types/attribute.types.ts | 152 +++++++ src/libs/vice/types/catalog.types.ts | 56 +++ src/libs/vice/types/datas.types.ts | 39 ++ src/libs/vice/types/hotspot.types.ts | 67 +++ src/libs/vice/types/ids.types.ts | 29 ++ src/libs/vice/types/index.ts | 16 + src/libs/vice/types/json-logic.types.ts | 28 ++ src/libs/vice/types/object.types.ts | 68 +++ src/libs/vice/types/option.types.ts | 41 ++ src/libs/vice/types/pricing.types.ts | 84 ++++ src/libs/vice/types/primitives.types.ts | 50 +++ src/libs/vice/types/rule.types.ts | 137 ++++++ src/libs/vice/types/section.types.ts | 138 +++++++ src/libs/vice/types/view.types.ts | 143 +++++++ 32 files changed, 2560 insertions(+), 461 deletions(-) rename src/libs/i18n/{z_i18n.test.ts => tests/i18n.test.ts} (99%) rename src/libs/logr/{z_logr.test.ts => tests/logr.test.ts} (99%) rename src/libs/logr/{z_logr_transports.test.ts => tests/logr_transports.test.ts} (97%) delete mode 100644 src/libs/olds/i18n/i18n.test.ts delete mode 100644 src/libs/olds/i18n/i18n.ts delete mode 100644 src/libs/olds/i18n/i18n.types.ts delete mode 100644 src/libs/olds/i18n/index.ts delete mode 100644 src/libs/olds/i18n/schema.ts delete mode 100644 src/libs/olds/itn/i18n.ts delete mode 100644 src/libs/olds/itn/i18n.types.ts delete mode 100644 src/libs/olds/itn/schema.ts create mode 100644 src/libs/vice/consts/messages.ts create mode 100644 src/libs/vice/engines/evaluator.engine.ts create mode 100644 src/libs/vice/engines/rule.engine.ts create mode 100644 src/libs/vice/engines/template-resolver.engine.ts create mode 100644 src/libs/vice/tests/evaluator.test.ts create mode 100644 src/libs/vice/types/api.types.ts create mode 100644 src/libs/vice/types/attribute.types.ts create mode 100644 src/libs/vice/types/catalog.types.ts create mode 100644 src/libs/vice/types/datas.types.ts create mode 100644 src/libs/vice/types/hotspot.types.ts create mode 100644 src/libs/vice/types/ids.types.ts create mode 100644 src/libs/vice/types/index.ts create mode 100644 src/libs/vice/types/json-logic.types.ts create mode 100644 src/libs/vice/types/object.types.ts create mode 100644 src/libs/vice/types/option.types.ts create mode 100644 src/libs/vice/types/pricing.types.ts create mode 100644 src/libs/vice/types/primitives.types.ts create mode 100644 src/libs/vice/types/rule.types.ts create mode 100644 src/libs/vice/types/section.types.ts create mode 100644 src/libs/vice/types/view.types.ts diff --git a/docs/logr.md b/docs/logr.md index cba1da0..75e16a1 100644 --- a/docs/logr.md +++ b/docs/logr.md @@ -1,6 +1,6 @@ # Logr -Sistema de logging estructurado con soporte nativo para mensajes localizados. Depende de `@/libs/i18n` para la resolución de mensajes al locale activo. +Sistema de logging estructurado con soporte nativo para mensajes localizados y sistema de transports extensible. Depende de `@/libs/i18n` para la resolución de mensajes al locale activo. --- @@ -8,10 +8,12 @@ Sistema de logging estructurado con soporte nativo para mensajes localizados. De ``` logr/ -├── index.ts # Barrel — punto de entrada público -├── logr.engine.ts # Factory: createLogr() -├── logr.types.ts # Tipos e interfaces -└── logr.test.ts +├── index.ts # Barrel — punto de entrada público +├── logr.engine.ts # Factory: createLogr() +├── logr.transports.ts # Transports built-in +├── logr.types.ts # Tipos e interfaces +├── logr.test.ts # Tests del engine +└── logr.transports.test.ts # Tests de los transports ``` --- @@ -20,10 +22,20 @@ logr/ ```ts // logr/index.ts — instancia global -import { createLogr, LogLevel } from './logr.engine'; +import { createLogr, LogLevel, consoleTransport, httpTransport } from './logr.engine'; import { i18n } from '@/libs/i18n/i18n.engine'; -export const logr = createLogr(i18n, { level: LogLevel.WARN }); +export const logr = createLogr(i18n, { + level : LogLevel.WARN, + transports: [ + consoleTransport(), + httpTransport({ + url : 'https://logs.myapp.com/ingest', + headers: { 'Authorization': 'Bearer my-token' }, + level : LogLevel.ERROR, // solo errores al servidor + }), + ] +}); ``` ```ts @@ -34,6 +46,8 @@ if (import.meta.env.DEV) { } ``` +> Si no se especifican transports, usa `consoleTransport()` por defecto. + --- ## API @@ -61,9 +75,6 @@ logr.warn('db', 'Connection timeout', { host: 'db.prod', ms: 3000 }); // LocaleRecord — se resuelve al locale activo automáticamente logr.error('auth', { es: 'Login fallido', en: 'Login failed' }); - -// Usando traducciones registradas en i18n -logr.warn('checkout', i18n.ts('checkout.total', { amount: 99, currency: '€' })); ``` Un log solo se procesa si su nivel es **mayor o igual** al nivel configurado: @@ -77,10 +88,10 @@ DEBUG(0) < INFO(1) < WARN(2) < ERROR(3) < NONE(4) Devuelve las entradas del historial en memoria. Los filtros son opcionales y se combinan con AND. ```ts -logr.getLogs() // todas las entradas -logr.getLogs({ level: LogLevel.ERROR }) // solo errores -logr.getLogs({ category: 'auth' }) // solo entradas de 'auth' -logr.getLogs({ since: new Date('2024-01-01') }) // desde una fecha +logr.getLogs() // todas las entradas +logr.getLogs({ level: LogLevel.ERROR }) // solo errores +logr.getLogs({ category: 'auth' }) // solo entradas de 'auth' +logr.getLogs({ since: new Date('2024-01-01') }) // desde una fecha logr.getLogs({ category: 'auth', level: LogLevel.WARN }) // combinados ``` @@ -88,7 +99,7 @@ logr.getLogs({ category: 'auth', level: LogLevel.WARN }) // combinados ### `serialize()` -Exporta el historial como JSON. Los mensajes se resuelven al locale activo **en el momento de la llamada**, no en el momento en que se registraron. +Exporta el historial como JSON. Los mensajes se resuelven al locale activo **en el momento de la llamada**. ```ts const json = logr.serialize(); @@ -105,8 +116,6 @@ const json = logr.serialize(); ### `clear()` -Vacía el historial en memoria. - ```ts logr.clear(); logr.getLogs(); // → [] @@ -114,8 +123,6 @@ logr.getLogs(); // → [] ### `setLevel(level)` -Cambia el nivel mínimo en tiempo de ejecución. - ```ts logr.setLevel(LogLevel.DEBUG) // activa todos los niveles logr.setLevel(LogLevel.NONE) // silencia todos los logs — útil en tests @@ -123,8 +130,6 @@ logr.setLevel(LogLevel.NONE) // silencia todos los logs — útil en tests ### `setMaxLogs(max)` -Cambia el número máximo de entradas a retener. Cuando se supera el límite, se descartan las más antiguas. - ```ts logr.setMaxLogs(5000) // desarrollo logr.setMaxLogs(500) // producción @@ -132,6 +137,96 @@ logr.setMaxLogs(500) // producción --- +## Transports + +Un transport es un destino de salida para las entradas de log. El engine emite cada entrada a todos los transports registrados, con el mensaje ya resuelto al locale activo. + +### `consoleTransport(options?)` + +Emite los logs a la consola usando el método apropiado según el nivel. + +```ts +consoleTransport() // con timestamp y prefijo +consoleTransport({ timestamp: false }) // sin timestamp +consoleTransport({ prefix: false }) // sin prefijo [category] +consoleTransport({ timestamp: false, prefix: false }) // solo el mensaje +``` + +| Opción | Tipo | Default | Descripción | +|-------------|-----------|---------|-------------| +| `timestamp` | `boolean` | `true` | Incluye el ISO timestamp en el output | +| `prefix` | `boolean` | `true` | Incluye el prefijo `[category]` | + +### `httpTransport(options)` + +Envía las entradas a un endpoint HTTP remoto via POST. Fire-and-forget — los errores de red no interrumpen la aplicación. + +```ts +httpTransport({ + url : 'https://logs.myapp.com/ingest', + headers: { 'Authorization': 'Bearer token' }, + level : LogLevel.ERROR // nivel mínimo independiente del logger +}) +``` + +| Opción | Tipo | Default | Descripción | +|-----------|---------------|-----------------|-------------| +| `url` | `string` | — | Endpoint que recibe los logs | +| `headers` | `Record` | `{}` | Headers adicionales | +| `level` | `LogLevel` | `LogLevel.ERROR` | Nivel mínimo para enviar al servidor | + +El body enviado tiene la forma: + +```json +{ + "timestamp": "2024-01-01T00:00:00.000Z", + "level" : 3, + "category" : "auth", + "message" : "Login failed", + "context" : { "userId": 42 } +} +``` + +### `callbackTransport(fn)` + +Invoca una función por cada entrada. El transport más flexible — útil para integraciones custom, Sentry, o capturar logs en tests sin output a consola. + +```ts +// Integración con Sentry +callbackTransport((entry, message) => { + if (entry.level >= LogLevel.ERROR) { + Sentry.captureMessage(message, { extra: entry.context }); + } +}) + +// Captura en tests — sin output a consola +const captured: string[] = []; +const logr = createLogr(i18n, { + level : LogLevel.DEBUG, + transports: [ callbackTransport((_, msg) => captured.push(msg)) ] +}); +``` + +### Transport custom + +Cualquier objeto que implemente la interfaz `Transport` es válido: + +```ts +import type { Transport } from '@/libs/logr'; + +const myTransport: Transport = { + write(entry, resolvedMessage) { + myService.send({ + level : entry.level, + message: resolvedMessage, + context: entry.context, + }); + } +}; +``` + +--- + ## Niveles | Nivel | Valor | Uso recomendado | @@ -158,14 +253,15 @@ logr.warn('auth', { es: 'Sesión expirada', en: 'Session expired' }); // output → "[auth] Session expired" ``` -> El mensaje se almacena como `I18nString` sin resolver. La resolución al locale ocurre en el momento del output a consola, y en `serialize()` en el momento de la llamada. +El mensaje se resuelve **una sola vez** por entrada y se pasa ya resuelto a todos los transports. + +> El mensaje se almacena como `I18nString` sin resolver. La resolución al locale ocurre en el momento del output, y en `serialize()` en el momento de la llamada. --- ## Configuración por entorno ```ts -// helpers de configuración rápida export function setupDevelopmentLogging(): void { logr.setLevel(LogLevel.DEBUG); logr.setMaxLogs(5000); @@ -185,14 +281,17 @@ export function setupTestLogging(): void { ## Tipos públicos -| Tipo | Descripción | -|------------------|-------------| -| `LogLevel` | Enum de niveles de severidad | -| `MessageCategory`| `string` — dominio funcional del log | -| `LogEntry` | Entrada individual del historial | -| `LogrOptions` | Opciones de configuración de `createLogr` | -| `LogFilters` | Filtros para `getLogs()` | -| `Logr` | Tipo de la instancia | +| Tipo | Descripción | +|-------------------------|-------------| +| `LogLevel` | Enum de niveles de severidad | +| `MessageCategory` | `string` — dominio funcional del log | +| `LogEntry` | Entrada individual del historial | +| `LogrOptions` | Opciones de configuración de `createLogr` | +| `LogFilters` | Filtros para `getLogs()` | +| `Transport` | Interfaz que deben implementar los transports | +| `ConsoleTransportOptions` | Opciones de `consoleTransport()` | +| `HttpTransportOptions` | Opciones de `httpTransport()` | +| `Logr` | Tipo de la instancia | --- @@ -202,4 +301,4 @@ export function setupTestLogging(): void { vitest ``` -Los tests usan un schema de i18n propio independiente del de producción. \ No newline at end of file +Los tests usan un schema de i18n propio independiente del de producción. Los transports se testean de forma aislada usando `callbackTransport` para capturar los mensajes sin output a consola. \ No newline at end of file diff --git a/src/libs/i18n/z_i18n.test.ts b/src/libs/i18n/tests/i18n.test.ts similarity index 99% rename from src/libs/i18n/z_i18n.test.ts rename to src/libs/i18n/tests/i18n.test.ts index bfeea65..0fcf3f8 100644 --- a/src/libs/i18n/z_i18n.test.ts +++ b/src/libs/i18n/tests/i18n.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createI18n } from './index'; -import type { TranslationNode, I18nString } from './index'; +import { createI18n } from '../index.ts'; +import type { TranslationNode, I18nString } from '../index.ts'; // ============================== diff --git a/src/libs/logr/z_logr.test.ts b/src/libs/logr/tests/logr.test.ts similarity index 99% rename from src/libs/logr/z_logr.test.ts rename to src/libs/logr/tests/logr.test.ts index 1ad092b..a3cde49 100644 --- a/src/libs/logr/z_logr.test.ts +++ b/src/libs/logr/tests/logr.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createLogr, LogLevel } from './index.ts'; +import { createLogr, LogLevel } from '../index.ts'; import { createI18n } from '@/libs/i18n'; import type { TranslationNode, SupportedLocale } from '@/libs/i18n'; diff --git a/src/libs/logr/z_logr_transports.test.ts b/src/libs/logr/tests/logr_transports.test.ts similarity index 97% rename from src/libs/logr/z_logr_transports.test.ts rename to src/libs/logr/tests/logr_transports.test.ts index aa6270f..91e1533 100644 --- a/src/libs/logr/z_logr_transports.test.ts +++ b/src/libs/logr/tests/logr_transports.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { consoleTransport, httpTransport, callbackTransport } from './logr.transports'; -import { createLogr, LogLevel } from './index'; +import { consoleTransport, httpTransport, callbackTransport } from '../logr.transports.ts'; +import { createLogr, LogLevel } from '../index.ts'; import { createI18n } from '@/libs/i18n'; import type { TranslationNode, SupportedLocale } from '@/libs/i18n'; -import type { LogEntry } from './logr.types'; +import type { LogEntry } from '../logr.types.ts'; // ============================================================================ // HELPERS @@ -104,7 +104,7 @@ describe('consoleTransport', () => { const t = consoleTransport(); t.write(makeEntry({ context: { userId: 42 } }), 'msg'); const args = spy.mock.calls[0]; - expect(args).toContain(JSON.stringify({ userId: 42 }) || expect.objectContaining({ userId: 42 })); + expect(args).toContainEqual(expect.objectContaining({ userId: 42 })); spy.mockRestore(); }); }); @@ -252,5 +252,4 @@ describe('múltiples transports', () => { // aislamiento habría que añadir try/catch en el engine expect(() => logr.warn('auth', { es: 'msg' })).toThrow(); }); -}); - +}); \ No newline at end of file diff --git a/src/libs/olds/i18n/i18n.test.ts b/src/libs/olds/i18n/i18n.test.ts deleted file mode 100644 index 0f027b8..0000000 --- a/src/libs/olds/i18n/i18n.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { i18n } from '@/libs/olds/i18n/index.ts'; // Importamos la instancia configurada - - - - -describe('I18N Modular System', () => { - - beforeEach(() => { - // Resetear al idioma por defecto antes de cada test - i18n.setLocale('es'); - }); - - describe('Core Functionality', () => { - it('debe cargar las traducciones iniciales del core', () => { - expect(i18n.t('core.loading')).toBe('Cargando...'); - expect(i18n.t('core.error', undefined, 'en')).toBe('An unexpected error occurred'); - }); - - it('debe cambiar el idioma globalmente', () => { - i18n.setLocale('en'); - expect(i18n.t('core.loading')).toBe('Loading...'); - }); - }); - - describe('Dynamic Registration (addResource)', () => { - it('debe permitir añadir nuevos recursos después de la instanciación', () => { - // Registramos un recurso que no existía inicialmente - i18n.addResource('es', { - ui: { - buttons: { save: 'Guardar' } - } - }); - - expect(i18n.t('ui.buttons.save' as any)).toBe('Guardar'); - }); - - it('debe registrar y ejecutar funciones dinámicas añadidas a posteriori', () => { - i18n.addResource('es', { - jsonLogic: { - errors: { - UNKNOWN_OPERATOR: (op: string) => `Op desconocido: ${op}` - } - } - }); - - // Validamos que la función se ejecute correctamente - expect(i18n.t('jsonLogic.errors.UNKNOWN_OPERATOR' as any, ['XOR'])).toBe('Op desconocido: XOR'); - }); - - it('debe sobrescribir traducciones existentes si se registra la misma clave', () => { - i18n.addResource('es', { - core: { loading: 'Espera un momento...' } - }); - - expect(i18n.t('core.loading')).toBe('Espera un momento...'); - }); - }); - - describe('Fallback & Edge Cases', () => { - it('debe hacer fallback al idioma por defecto si la clave falta en el idioma actual', () => { - i18n.setLocale('en'); - // 'ui.buttons.save' solo lo añadimos en 'es' en el test anterior - // (Asumiendo que los tests comparten instancia o lo añadimos aquí) - i18n.addResource('es', { common: { cancel: 'Cancelar' } }); - - expect(i18n.t('common.cancel' as any)).toBe('Cancelar'); - }); - - it('debe devolver la clave si no existe en ningún idioma', () => { - // @ts-expect-error - Probando comportamiento ante claves inexistentes - expect(i18n.t('non.existent.key')).toBe('non.existent.key'); - }); - - it('debe manejar correctamente la interpolación con valores 0 o strings vacíos', () => { - i18n.addResource('es', { - test: { zero: 'Valor: {{val}}', empty: 'Texto: {{val}}' } - }); - - expect(i18n.t('test.zero' as any, { val: 0 })).toBe('Valor: 0'); - expect(i18n.t('test.empty' as any, { val: '' })).toBe('Texto: '); - }); - }); -}); - diff --git a/src/libs/olds/i18n/i18n.ts b/src/libs/olds/i18n/i18n.ts deleted file mode 100644 index b175fdc..0000000 --- a/src/libs/olds/i18n/i18n.ts +++ /dev/null @@ -1,78 +0,0 @@ -// i18n.ts -import type { - DotNestedKeys, - GetTypeAtPath, - InterpolationParams, - TranslationStore -} from './i18n-types'; - -export function createI18n( - initialStore: Partial> = {}, // Ahora puede empezar vacío - defaultLocale: TLocale -) { - let currentLocale: TLocale = defaultLocale; - - // El almacén plano donde se acumulará todo - const flatStore = {} as Record>; - - function flatten(obj: any, prefix = ''): Record { - return Object.keys(obj).reduce((acc: any, k: string) => { - const path = prefix ? `${prefix}.${k}` : k; - if (typeof obj[k] === 'object' && obj[k] !== null && !Array.isArray(obj[k])) { - Object.assign(acc, flatten(obj[k], path)); - } else { - acc[path] = obj[k]; - } - return acc; - }, {}); - } - - // Función para procesar y añadir recursos - const addResource = (locale: TLocale, resource: any) => { - if (!flatStore[locale]) flatStore[locale] = {}; - const flattened = flatten(resource); - Object.assign(flatStore[locale], flattened); - }; - - // Inicializar con lo que venga en el constructor - for (const loc in initialStore) { - addResource(loc as TLocale, initialStore[loc]); - } - - return { - setLocale: (l: TLocale) => { currentLocale = l; }, - getLocale: () => currentLocale, - - /** - * Añade nuevas traducciones a un idioma específico después de la instanciación. - * Útil para cargar traducciones de módulos o plugins bajo demanda. - */ - addResource, - - t: >( - key: K, - args?: GetTypeAtPath extends (...args: infer P) => any ? P : InterpolationParams, - overrideLocale?: TLocale - ): string => { - const activeLocale = overrideLocale || currentLocale; - const entry = flatStore[activeLocale]?.[key] || flatStore[defaultLocale]?.[key]; - - if (!entry) return key; - - if (typeof entry === 'function') { - const params = Array.isArray(args) ? args : []; - return entry(...params); - } - - if (typeof entry === 'string' && args && !Array.isArray(args)) { - const params = args as InterpolationParams; - return entry.replace(/\{\{([^}]+)}}/g, (match: string, k: string): string => { - const value = params[k]; - return value !== undefined ? String(value) : match; - }); - } - - return String(entry); - } - }; -} \ No newline at end of file diff --git a/src/libs/olds/i18n/i18n.types.ts b/src/libs/olds/i18n/i18n.types.ts deleted file mode 100644 index 5eeb2bc..0000000 --- a/src/libs/olds/i18n/i18n.types.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Determina si un nodo es un terminal (un string o una función) - * o si debemos seguir navegando por el objeto. - */ -export type IsTerminal = T extends (...args: any[]) => any - ? true - : T extends object ? false : true; - -/** - * Genera claves en notación de puntos (ej: 'errors.UNKNOWN_OPERATOR') - */ -export type DotNestedKeys = T extends object - ? { - [K in keyof T & string]: IsTerminal extends true - ? K - : `${K}.${DotNestedKeys}` - }[keyof T & string] - : ''; - -/** - * Extrae el tipo exacto (String o Función) de una ruta específica - */ -export type GetTypeAtPath = Path extends `${infer Head}.${infer Tail}` - ? Head extends keyof T ? GetTypeAtPath : never - : Path extends keyof T ? T[Path] : never; - -/** - * Estructura para variables de interpolación {{var}} - */ -export type InterpolationParams = Record; - -/** - * Definición del almacén de traducciones - */ -export type TranslationStore = { - [L in TLocale]: TSchema; -}; \ No newline at end of file diff --git a/src/libs/olds/i18n/index.ts b/src/libs/olds/i18n/index.ts deleted file mode 100644 index 6eecb70..0000000 --- a/src/libs/olds/i18n/index.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { TranslationStore } from './i18n-types'; -import type { AppSchema } from "@/libs/olds/i18n/schema.ts"; -import { createI18n } from './i18n.ts'; - - -/** - * 1. IDIOMAS SOPORTADOS - */ -export type SupportedLocales = 'es' | 'en'; - -/** - * 2. TRADUCCIONES INICIALES (CORE) - * Solo incluimos lo mínimo indispensable para que la app arranque. - */ -const initialTranslations: Partial> = { - es: { - core: { - error : "Ha ocurrido un error inesperado", - loading: "Cargando..." - } - }, - en: { - core: { - error : "An unexpected error occurred", - loading: "Loading..." - } - } -}; - -/** - * 4. INSTANCIA EXPORTABLE - */ -export const i18n = createI18n( - initialTranslations, - 'es' -); - -// Helper para exportar directamente la función de traducción -export const t = i18n.t; \ No newline at end of file diff --git a/src/libs/olds/i18n/schema.ts b/src/libs/olds/i18n/schema.ts deleted file mode 100644 index 5cee04b..0000000 --- a/src/libs/olds/i18n/schema.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * 1. DEFINICIÓN DEL ESQUEMA GLOBAL - * Reúne todas las claves posibles de tu aplicación o librería. - * Si usas módulos opcionales, puedes definirlos como parciales. - */ -export type AppSchema = { - // Diccionario base (siempre presente) - core: { - error: string; - loading: string; - }; - // Diccionarios de módulos (se pueden llenar vía addResource) - jsonLogic?: { - errors: { - UNKNOWN_OPERATOR: (op: string) => string; - MISSING_DATA: (field: string) => string; - }; - }; - ui?: { - buttons: { - save: string; - delete: string; - }; - }; -}; \ No newline at end of file diff --git a/src/libs/olds/itn/i18n.ts b/src/libs/olds/itn/i18n.ts deleted file mode 100644 index 1c130d0..0000000 --- a/src/libs/olds/itn/i18n.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { - Paths, - GetTypeAtPath, - ParamsFor, - HasParams -} from './i18n.types.ts'; -import { translations } from './schema.ts'; -import type { TranslationSchema } from './schema.ts'; -import type { SupportedLocale, DefaultLocale } from './i18n.types.ts'; - -let currentLocale: SupportedLocale = 'es'; - -export function setLocale(locale: SupportedLocale) { - currentLocale = locale; -} - -export function getLocale() { - return currentLocale; -} - -function resolvePath(obj: any, path: string): any { - return path.split('.').reduce((acc, key) => acc?.[key], obj); -} - -export function t< - P extends Paths, - TType = GetTypeAtPath ->( - path: P, - ...args: HasParams extends true - ? [params: ParamsFor] - : [] -): string { - - const value = resolvePath(translations, path); - - if (!value) { - throw new Error(`Missing translation key: ${path}`); - } - - const record = - typeof value === 'function' - ? value(args[0]) - : value; - - return ( - record[currentLocale] ?? - record['es'] // fallback default - ); -} \ No newline at end of file diff --git a/src/libs/olds/itn/i18n.types.ts b/src/libs/olds/itn/i18n.types.ts deleted file mode 100644 index 67edef9..0000000 --- a/src/libs/olds/itn/i18n.types.ts +++ /dev/null @@ -1,81 +0,0 @@ -// ============================== -// LOCALES -// ============================== - -export type DefaultLocale = 'es'; - -export type SupportedLocale = - | DefaultLocale - | 'en' - | 'de' - | 'fr' - | 'it' - | 'pt' - | 'ca' - | 'eu' - | 'gl'; - -// ============================== -// LOCALIZED TYPES -// ============================== - -export type LocaleRecord = { - [K in L]?: string; -} & { - [K in DefaultLocale]: string; -}; - -export type TranslationValue = - | LocaleRecord - | ((params: any) => LocaleRecord); - -// ============================== -// TYPE UTILITIES -// ============================== - -type DotPrefix = T extends '' ? '' : `.${T}`; - -type Prev = [never, 0, 1, 2, 3, 4, 5, 6]; - -type Join = - P extends string - ? `${K & string}.${P}` - : never; - -export type Paths< - T, - D extends number = 6 -> = - [D] extends [never] - ? never - : T extends object - ? { - [K in keyof T & string]: - | K - | Join> - }[keyof T & string] - : never; - - -export type GetTypeAtPath< - T, - P extends string -> = - P extends `${infer K}.${infer Rest}` - ? K extends keyof T - ? GetTypeAtPath - : never - : P extends keyof T - ? T[P] - : never; - - -export type ParamsFor = - T extends (params: infer P) => any - ? P - : never; - -export type HasParams = - T extends (params: any) => any - ? true - : false; \ No newline at end of file diff --git a/src/libs/olds/itn/schema.ts b/src/libs/olds/itn/schema.ts deleted file mode 100644 index 3c0e0c2..0000000 --- a/src/libs/olds/itn/schema.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { SupportedLocale, TranslationValue } from './i18n.types.ts'; - -export const translations = { - checkout: { - pay: { - es: "Pagar", - en: "Pay" - }, - - total: (params: { amount: number }) => ({ - es: `Total: ${params.amount}€`, - en: `Total: $${params.amount}` - }) - }, - - common: { - ok: { - es: "Aceptar", - en: "OK" - } - } - -} satisfies Record; - -export type TranslationSchema = typeof translations; \ No newline at end of file diff --git a/src/libs/vice/consts/messages.ts b/src/libs/vice/consts/messages.ts new file mode 100644 index 0000000..932b5b1 --- /dev/null +++ b/src/libs/vice/consts/messages.ts @@ -0,0 +1,182 @@ +/** + * ============================================================================ + * ENGINE MESSAGES + * ============================================================================ + * + * Mensajes centralizados del engine — errores, warnings e info. + * Sin strings hardcodeados en el código. + * + * Dos tipos: + * - Técnicos (string): solo los ven developers — logs internos + * - Usuario (I18nString): los ve el usuario final — validaciones, UI + */ + +import type { I18nString } from '@/libs/i18n'; +import type { AttrID, OptionID, ObjectID, SectionID, ViewID } from '../types'; + +// ============================================================================ +// CATEGORIES — para el sistema logr +// ============================================================================ + +export const ENGINE_CATEGORIES = { + RULE_ENGINE : 'rule-engine', + JSON_LOGIC : 'json-logic', + TEMPLATE_RESOLVER: 'template-resolver', + PRICING : 'pricing', + SELECTION : 'selection', + CONFIGURATION : 'configuration', +} as const; + +export type EngineCategory = typeof ENGINE_CATEGORIES[keyof typeof ENGINE_CATEGORIES]; + +// ============================================================================ +// RULE ENGINE — mensajes técnicos +// ============================================================================ + +export const RULE_ENGINE_ERRORS = { + + RULE_EVALUATION_FAILED: (ruleId: string, error: string): string => + `Error evaluating rule "${ruleId}": ${error}`, + +} as const; + +// ============================================================================ +// JSON LOGIC — mensajes técnicos +// ============================================================================ + +export const JSON_LOGIC_WARNINGS = { + + UNKNOWN_OPERATOR: (operator: string): string => + `Unknown JsonLogic operator: "${operator}"`, + + DIVISION_BY_ZERO: (): string => + `Division by zero in JsonLogic expression`, + +} as const; + +// ============================================================================ +// TEMPLATE RESOLVER — mensajes técnicos +// ============================================================================ + +export const TEMPLATE_RESOLVER_ERRORS = { + + OBJECT_NOT_FOUND: (objectId: ObjectID): string => + `Object "${objectId}" not found in catalog`, + + SECTION_NOT_FOUND: (sectionId: SectionID, objectId: ObjectID): string => + `Section "${sectionId}" not found in object "${objectId}"`, + + SECTION_NOT_VISUAL: (sectionId: SectionID): string => + `Section "${sectionId}" is not visual — cannot resolve image template`, + + VIEW_NOT_FOUND: (viewId: ViewID, sectionId: SectionID): string => + `View "${viewId}" not found in section "${sectionId}"`, + + ATTRIBUTE_NOT_FOUND: (attrCode: string): string => + `Attribute with code "${attrCode}" not found in section or object attributes`, + + ATTRIBUTE_NO_VALUE: (attrId: AttrID): string => + `Attribute "${attrId}" has no value in state — all attributes must have a default`, + + OPTION_NOT_FOUND: (optionId: string): string => + `Option "${optionId}" not found in catalog`, + + TEMPLATE_UNRESOLVABLE: (template: string): string => + `Could not resolve template "${template}" — using fallback image`, + +} as const; + +export const TEMPLATE_RESOLVER_WARNINGS = { + + UNSUPPORTED_STRATEGY: (strategy: string): string => + `TemplateResolver only handles "static_image" strategy — got "${strategy}"`, + + NO_FALLBACK_IMAGE: (objectId?: ObjectID, sectionId?: SectionID): string => + `No fallback image configured` + + (objectId ? ` for object "${objectId}"` : '') + + (sectionId ? ` section "${sectionId}"` : ''), + +} as const; + +// ============================================================================ +// PRICING ENGINE — mensajes técnicos +// ============================================================================ + +export const PRICING_ERRORS = { + + OPTION_NOT_FOUND: (optionId: OptionID): string => + `Option "${optionId}" not found in catalog — cannot calculate price`, + + EXPRESSION_FAILED: (optionId: OptionID, error: string): string => + `Error evaluating price expression for option "${optionId}": ${error}`, + +} as const; + +// ============================================================================ +// SELECTION — mensajes técnicos +// ============================================================================ + +export const SELECTION_ERRORS = { + + ATTRIBUTE_NOT_FOUND: (attrId: AttrID): string => + `Attribute "${attrId}" not found`, + + INVALID_OPTION: (attrId: AttrID, optionId: OptionID): string => + `Option "${optionId}" is not valid for attribute "${attrId}"`, + +} as const; + +// ============================================================================ +// MENSAJES DE USUARIO — I18nString +// Los ve el usuario final en la UI +// ============================================================================ + +export const USER_MESSAGES = { + + VALIDATION: { + CONFIGURATION_INVALID: { + es: 'La configuración tiene errores que deben resolverse', + en: 'The configuration has errors that must be resolved', + } satisfies I18nString, + + ATTRIBUTE_REQUIRED: { + es: 'Este campo es obligatorio', + en: 'This field is required', + } satisfies I18nString, + + VALUE_NOT_ALLOWED: { + es: 'Esta opción no está disponible con la configuración actual', + en: 'This option is not available with the current configuration', + } satisfies I18nString, + + VALUE_FORBIDDEN: { + es: 'Esta opción no es compatible con otras selecciones', + en: 'This option is not compatible with other selections', + } satisfies I18nString, + }, + + IMAGE: { + LOADING: { + es: 'Cargando imagen...', + en: 'Loading image...', + } satisfies I18nString, + + LOAD_ERROR: { + es: 'No se pudo cargar la imagen', + en: 'Could not load the image', + } satisfies I18nString, + }, + + PRICING: { + CONSULTATION: { + es: 'Precio bajo consulta', + en: 'Price on request', + } satisfies I18nString, + + CALCULATING: { + es: 'Calculando precio...', + en: 'Calculating price...', + } satisfies I18nString, + }, + +} as const; \ No newline at end of file diff --git a/src/libs/vice/engines/evaluator.engine.ts b/src/libs/vice/engines/evaluator.engine.ts new file mode 100644 index 0000000..ae7f6df --- /dev/null +++ b/src/libs/vice/engines/evaluator.engine.ts @@ -0,0 +1,183 @@ +/** + * ============================================================================ + * JSON LOGIC EVALUATOR + * ============================================================================ + */ +import type { Logr } from '@/libs/logr'; +import type { JsonLogic } from '../types'; +import { ENGINE_CATEGORIES, JSON_LOGIC_WARNINGS } from '../consts/messages'; + +/** + * Evaluador de expresiones JsonLogic. + * + * Implementación propia sin dependencias externas. + * Soporta: variables, lógicos, comparación, condicionales, arrays y matemáticos. + * + * Las variables referencian el estado de selección normalizado: + * 'at:calidad' → 'at_calidad' en los datos pasados al evaluador. + */ +export class JsonLogicEvaluator { + private readonly logr: Logr; + + constructor(logr: Logr) { + this.logr = logr; + } + + /** + * Evalúa una expresión JsonLogic contra un contexto de datos. + * + * @param expression - Expresión JsonLogic a evaluar + * @param data - Contexto de datos — normalmente el estado de selección normalizado + * @returns - Resultado de la evaluación — tipo depende de la expresión + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + evaluate(expression: JsonLogic, data: Record): unknown { + // Primitivos + if (expression === null) return null; + if (typeof expression === 'boolean') return expression; + if (typeof expression === 'number') return expression; + if (typeof expression === 'string') return expression; + if (Array.isArray(expression)) return expression.map(item => this.evaluate(item, data)); + if (typeof expression !== 'object') return expression; + + const expr = expression as Record; + const operator = Object.keys(expr)[0]; + const args = expr[operator]; + + switch (operator) { + + // ---------------------------------------------------------------- + // Variables + // ---------------------------------------------------------------- + + case 'var': + return this.getVar(args, data); + + // ---------------------------------------------------------------- + // Igualdad + // ---------------------------------------------------------------- + + case '==': + case '===': + return this.evaluateAll(args, data).every((v, i, arr) => + i === 0 || v === arr[0] + ); + + case '!=': + case '!==': { + const [a, b] = this.evaluateAll(args, data); + return a !== b; + } + + case '!': + return !this.evaluate(args as JsonLogic, data); + + // ---------------------------------------------------------------- + // Lógicos + // ---------------------------------------------------------------- + + case 'and': + return this.evaluateAll(args, data).every(Boolean); + + case 'or': + return this.evaluateAll(args, data).some(Boolean); + + // ---------------------------------------------------------------- + // Comparación + // ---------------------------------------------------------------- + + case '>': { + const [a, b] = this.evaluateAll(args, data); + return (a as number) > (b as number); + } + case '>=': { + const [a, b] = this.evaluateAll(args, data); + return (a as number) >= (b as number); + } + case '<': { + const [a, b] = this.evaluateAll(args, data); + return (a as number) < (b as number); + } + case '<=': { + const [a, b] = this.evaluateAll(args, data); + return (a as number) <= (b as number); + } + + // ---------------------------------------------------------------- + // Condicional + // ---------------------------------------------------------------- + + case 'if': { + const [condition, thenBranch, elseBranch] = this.evaluateAll(args, data); + return condition ? thenBranch : (elseBranch ?? null); + } + + // ---------------------------------------------------------------- + // Arrays + // ---------------------------------------------------------------- + + case 'in': { + const [item, array] = this.evaluateAll(args, data); + return Array.isArray(array) && array.includes(item); + } + + // ---------------------------------------------------------------- + // Matemáticos + // ---------------------------------------------------------------- + + case '+': + return this.evaluateAll(args, data).reduce((a, b) => (a as number) + (b as number), 0); + + case '-': { + const nums = this.evaluateAll(args, data); + return nums.length === 1 + ? -(nums[0] as number) + : (nums[0] as number) - (nums[1] as number); + } + + case '*': + return this.evaluateAll(args, data).reduce((a, b) => (a as number) * (b as number), 1); + + case '/': { + const [dividend, divisor] = this.evaluateAll(args, data); + if ((divisor as number) === 0) { + this.logr.warn(ENGINE_CATEGORIES.JSON_LOGIC, JSON_LOGIC_WARNINGS.DIVISION_BY_ZERO()); + return null; + } + return (dividend as number) / (divisor as number); + } + + case '%': { + const [a, b] = this.evaluateAll(args, data); + return (a as number) % (b as number); + } + + // ---------------------------------------------------------------- + // Operador desconocido + // ---------------------------------------------------------------- + + default: + this.logr.warn(ENGINE_CATEGORIES.JSON_LOGIC, JSON_LOGIC_WARNINGS.UNKNOWN_OPERATOR(operator)); + return null; + } + } + + private evaluateAll(args: unknown, data: Record): unknown[] { + return (Array.isArray(args) ? args : [args]).map(arg => + this.evaluate(arg as JsonLogic, data) + ); + } + + private getVar(path: unknown, data: Record): unknown { + const resolvedPath = typeof path === 'string' + ? path + : String(this.evaluate(path as JsonLogic, data)); + + return resolvedPath + .split('.') + .reduce((current, part) => { + if (current === undefined || current === null) return undefined; + return (current as Record)[part]; + }, data); + } +} \ No newline at end of file diff --git a/src/libs/vice/engines/rule.engine.ts b/src/libs/vice/engines/rule.engine.ts new file mode 100644 index 0000000..8d4e895 --- /dev/null +++ b/src/libs/vice/engines/rule.engine.ts @@ -0,0 +1,298 @@ +/** + * ============================================================================ + * RULE ENGINE + * ============================================================================ + * + * Evalúa ValidationRules usando el JsonLogicEvaluator propio. + * Determina qué atributos están permitidos/prohibidos/requeridos + * en función del estado de selección actual. + */ + +import type { Logr } from '@/libs/logr'; +import type { + ValidationRule, + ValidationRuleAction, + AttrID, + OptionID, + Value, + Severity +} from '../types'; +import { JsonLogicEvaluator } from './evaluator.engine'; +import { ENGINE_CATEGORIES, RULE_ENGINE_ERRORS } from '../consts/messages'; + + +// ============================================================================ +// TYPES +// ============================================================================ + +/** + * Estado de selección actual del usuario. + * Mapa de AttrID → valor seleccionado. + */ +export type SelectionMap = Record; + +/** + * Resultado de evaluar una regla contra el estado actual. + */ +export interface RuleEvaluationResult { + rule : ValidationRule; + triggered: boolean; + action? : ValidationRuleAction; +} + +/** + * Resultado consolidado de todas las reglas para un atributo concreto. + */ +export interface AttributeRuleResult { + attrId : AttrID; + /** Lista blanca — si está definida, solo estos valores son válidos */ + allowedValues? : OptionID[]; + /** Lista negra — estos valores están prohibidos */ + forbiddenValues?: OptionID[]; + /** Si el atributo debe tener un valor seleccionado */ + required : boolean; + /** Violaciones activas para este atributo */ + violations : RuleViolation[]; +} + +/** + * Violación de una regla — la condición se cumple pero el valor actual la incumple. + */ +export interface RuleViolation { + rule : ValidationRule; + severity: Severity; +} + +// ============================================================================ +// RULE ENGINE +// ============================================================================ + +export class RuleEngine { + private readonly evaluator: JsonLogicEvaluator; + private readonly logr : Logr; + + constructor(logr: Logr) { + this.logr = logr; + this.evaluator = new JsonLogicEvaluator(logr); + } + + // ------------------------------------------------------------------------- + // Evaluación + // ------------------------------------------------------------------------- + + /** + * Evalúa todas las reglas contra el estado actual. + * Las reglas se ordenan por prioridad descendente antes de evaluarse. + */ + evaluateAll( + rules: ValidationRule[], + state: SelectionMap + ): RuleEvaluationResult[] { + return [...rules] + .sort((a, b) => b.priority - a.priority) + .map(rule => this.evaluateRule(rule, state)); + } + + /** + * Evalúa una regla concreta contra el estado actual. + */ + evaluateRule( + rule : ValidationRule, + state: SelectionMap + ): RuleEvaluationResult { + try { + const data = { attributes: normalizeKeys(state) }; + const triggered = Boolean(this.evaluator.evaluate(rule.condition, data)); + + return triggered + ? { rule, triggered: true, action: rule.action } + : { rule, triggered: false }; + + } catch (error) { + this.logr.error( + ENGINE_CATEGORIES.RULE_ENGINE, + RULE_ENGINE_ERRORS.RULE_EVALUATION_FAILED(rule.id, String(error)), + { ruleId: rule.id } + ); + return { rule, triggered: false }; + } + } + + // ------------------------------------------------------------------------- + // Resultados por atributo + // ------------------------------------------------------------------------- + + /** + * Consolida los resultados de todas las reglas por atributo. + * Para cada atributo devuelve: valores permitidos, prohibidos, + * si es requerido y las violaciones activas. + */ + getAttributeResults( + rules: ValidationRule[], + state: SelectionMap + ): Map { + const results = new Map(); + const evaluated = this.evaluateAll(rules, state); + + for (const { triggered, action, rule } of evaluated) { + if (!triggered || !action) continue; + + const attrId = action.targetAttr; + + if (!results.has(attrId)) { + results.set(attrId, { + attrId, + allowedValues : undefined, + forbiddenValues: [], + required : false, + violations : [] + }); + } + + const result = results.get(attrId)!; + + switch (action.type) { + + case 'allow': + // Intersección si ya hay lista blanca — la restricción se acumula + result.allowedValues = result.allowedValues + ? result.allowedValues.filter(v => action.values.includes(v)) + : (action.values as OptionID[]); + break; + + case 'forbid': + result.forbiddenValues = [ + ...(result.forbiddenValues ?? []), + ...(action.values as OptionID[]) + ]; + // Violación si el valor actual está prohibido + if (action.values.includes(state[attrId])) { + result.violations.push({ rule, severity: rule.severity }); + } + break; + + case 'require': + result.required = true; + // Violación si no hay valor seleccionado + if (!state[attrId]) { + result.violations.push({ rule, severity: rule.severity }); + } + break; + + case 'suggest': + // Informativo — no genera violaciones + break; + } + } + + return results; + } + + // ------------------------------------------------------------------------- + // API pública + // ------------------------------------------------------------------------- + + /** + * Verifica si un valor concreto está permitido para un atributo + * dado el estado de selección actual. + */ + isValueAllowed( + attrId: AttrID, + value : Value, + rules : ValidationRule[], + state : SelectionMap + ): boolean { + const result = this.getAttributeResults(rules, state).get(attrId); + if (!result) return true; + + if (result.forbiddenValues?.includes(value as OptionID)) return false; + if (result.allowedValues && !result.allowedValues.includes(value as OptionID)) return false; + + return true; + } + + /** + * Devuelve los valores permitidos para un atributo dado el estado actual. + * Si no hay restricciones activas devuelve undefined — cualquier valor es válido. + */ + getAllowedValues( + attrId: AttrID, + rules : ValidationRule[], + state : SelectionMap + ): OptionID[] | undefined { + return this.getAttributeResults(rules, state).get(attrId)?.allowedValues; + } + + /** + * Verifica si un atributo es requerido dado el estado actual. + */ + isRequired( + attrId: AttrID, + rules : ValidationRule[], + state : SelectionMap + ): boolean { + return this.getAttributeResults(rules, state).get(attrId)?.required ?? false; + } + + /** + * Devuelve todas las violaciones activas ordenadas por severidad. + * error → warning → info + */ + getViolations( + rules: ValidationRule[], + state: SelectionMap + ): RuleViolation[] { + const violations: RuleViolation[] = []; + + for (const result of this.getAttributeResults(rules, state).values()) { + violations.push(...result.violations); + } + + return violations.sort((a, b) => + severityOrder(a.severity) - severityOrder(b.severity) + ); + } + + /** + * Verifica si el estado actual es válido. + * Un estado es válido si no hay violaciones de severidad 'error'. + */ + isValid( + rules: ValidationRule[], + state: SelectionMap + ): boolean { + return !this.getViolations(rules, state).some(v => v.severity === 'error'); + } +} + +// ============================================================================ +// HELPERS +// ============================================================================ + +/** + * Normaliza las claves del estado para JsonLogic. + * 'at:calidad' → 'at_calidad' (los ':' no son válidos como nombres de variable) + */ +function normalizeKeys(state: SelectionMap): Record { + const normalized: Record = {}; + for (const [key, value] of Object.entries(state)) { + normalized[key.replace(':', '_')] = value; + } + return normalized; +} + +function severityOrder(severity: Severity): number { + switch (severity) { + case 'error' : return 0; + case 'warning': return 1; + case 'info' : return 2; + default : return 3; + } +} + +// ============================================================================ +// SINGLETON +// ============================================================================ + +// El singleton se crea en el wiring del engine, no aquí. +// Ejemplo: export const ruleEngine = new RuleEngine(logr); \ No newline at end of file diff --git a/src/libs/vice/engines/template-resolver.engine.ts b/src/libs/vice/engines/template-resolver.engine.ts new file mode 100644 index 0000000..68fc539 --- /dev/null +++ b/src/libs/vice/engines/template-resolver.engine.ts @@ -0,0 +1,290 @@ +/** + * ============================================================================ + * TEMPLATE RESOLVER + * ============================================================================ + * + * Resuelve templates de imagen a URLs completas combinando: + * - basePath heredado en cascada (vista → sección → objeto → catálogo) + * - template con placeholders {at:} resueltos al code de la opción activa + */ + +import type { Logr } from '@/libs/logr'; +import { + type ConfigurationCatalog, + type ConfigurableObject, + type VisualSection, + type SectionView, + type StaticImageConfig, + type ObjectID, + type SectionID, + type ViewID, isOptionID, type OptionID +} from '../types'; +import { + ENGINE_CATEGORIES, + TEMPLATE_RESOLVER_ERRORS, + TEMPLATE_RESOLVER_WARNINGS +} from '../consts/messages'; +import type { SelectionMap } from './rule.engine'; + +// ============================================================================ +// TYPES +// ============================================================================ + +/** + * Contexto necesario para resolver un template. + * Identifica el objeto, sección y vista activos. + */ +export interface ResolveContext { + objectId : ObjectID; + sectionId: SectionID; + viewId : ViewID; +} + +/** + * Resultado de la resolución de un template. + */ +export interface ResolveResult { + /** URL completa resuelta — null si se usó fallback */ + url : string; + /** Si se usó el fallbackImage en lugar de la URL generada */ + isFallback : boolean; +} + +// ============================================================================ +// TEMPLATE RESOLVER +// ============================================================================ + +/** Regex para extraer placeholders del tipo {at:code} */ +const PLACEHOLDER_RE = /\{at:([^}]+)\}/g; + +export class TemplateResolver { + private readonly logr: Logr; + + constructor(logr: Logr) { + this.logr = logr; + } + + /** + * Resuelve un template de imagen a una URL completa. + * + * Proceso: + * 1. Resuelve el basePath en cascada (vista → sección → objeto → catálogo) + * 2. Extrae los placeholders {at:} del template + * 3. Resuelve cada placeholder al code de la opción activa en el catálogo + * 4. Concatena basePath + template resuelto + * + * Si algún placeholder no se puede resolver, usa fallbackImage. + */ + resolve( + context : ResolveContext, + state : SelectionMap, + catalog : ConfigurationCatalog + ): ResolveResult { + const object = catalog.objects[context.objectId]; + const section = object?.sections[context.sectionId]; + + if (!object || !section) { + this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.OBJECT_NOT_FOUND(context.objectId), { context }); + return this.fallback(catalog, object, section as VisualSection | undefined); + } + + if (section.kind !== 'visual') { + this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.SECTION_NOT_VISUAL(context.sectionId), { context }); + return this.fallback(catalog, object, undefined); + } + + const view = section.views[context.viewId]; + + if (!view) { + this.logr.error(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_ERRORS.VIEW_NOT_FOUND(context.viewId, context.sectionId), { context }); + return this.fallback(catalog, object, section); + } + + if (view.visualConfig.strategy !== 'static_image') { + this.logr.warn(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_WARNINGS.UNSUPPORTED_STRATEGY(view.visualConfig.strategy), { context }); + return this.fallback(catalog, object, section); + } + + return this.resolveStaticImage( + view.visualConfig, + view, + section, + object, + catalog, + state, + context + ); + } + + // ------------------------------------------------------------------------- + // Resolución static_image + // ------------------------------------------------------------------------- + + private resolveStaticImage( + config : StaticImageConfig, + view : SectionView, + section : VisualSection, + object : ConfigurableObject, + catalog : ConfigurationCatalog, + state : SelectionMap, + context : ResolveContext + ): ResolveResult { + const basePath = this.resolveBasePath(config, view, section, object, catalog); + const resolved = this.resolveTemplate(config.template, state, catalog, context); + + if (resolved === null) { + this.logr.error( + ENGINE_CATEGORIES.TEMPLATE_RESOLVER, + TEMPLATE_RESOLVER_ERRORS.TEMPLATE_UNRESOLVABLE(config.template), + { context } + ); + return this.fallback(catalog, object, section); + } + + return { url: `${basePath}${resolved}`, isFallback: false }; + } + + // ------------------------------------------------------------------------- + // Resolución de template + // ------------------------------------------------------------------------- + + /** + * Sustituye cada {at:} por el code de la opción activa. + * Devuelve null si algún placeholder no se puede resolver. + */ + private resolveTemplate( + template: string, + state : SelectionMap, + catalog : ConfigurationCatalog, + context : ResolveContext + ): string | null { + let result = template; + let success = true; + + result = result.replace(PLACEHOLDER_RE, (_, attrCode: string) => { + const resolved = this.resolvePlaceholder(attrCode, state, catalog, context); + + if (resolved === null) { + success = false; + return ''; + } + + return resolved; + }); + + return success ? result : null; + } + + /** + * Resuelve un placeholder {at:} al code de la opción seleccionada. + * + * Busca el atributo por code en: + * 1. Atributos de la sección + * 2. Atributos globales del objeto + * + * Luego resuelve el OptionID seleccionado al code de la OptionDefinition. + */ + private resolvePlaceholder( + attrCode: string, + state : SelectionMap, + catalog : ConfigurationCatalog, + context : ResolveContext + ): string | null { + const object = catalog.objects[context.objectId]; + const section = object?.sections[context.sectionId]; + + // Buscar atributo por code — primero en sección, luego en objeto + const sectionAttrs = section?.kind === 'visual' ? section.attributes : []; + const allAttrs = [...sectionAttrs, ...object.attributes]; + const attr = allAttrs.find(a => a.code === attrCode); + + if (!attr) { + this.logr.error( + ENGINE_CATEGORIES.TEMPLATE_RESOLVER, + TEMPLATE_RESOLVER_ERRORS.ATTRIBUTE_NOT_FOUND(attrCode), + { context, attrCode } + ); + return null; + } + + // Obtener el valor seleccionado del estado + const selectedValue = state[attr.id]; + + if (!selectedValue) { + this.logr.error( + ENGINE_CATEGORIES.TEMPLATE_RESOLVER, + TEMPLATE_RESOLVER_ERRORS.ATTRIBUTE_NO_VALUE(attr.id), + { context, attrId: attr.id } + ); + return null; + } + + // Resolver OptionID → code de la OptionDefinition + if (!isOptionID(selectedValue)) { + this.logr.error( + ENGINE_CATEGORIES.TEMPLATE_RESOLVER, + TEMPLATE_RESOLVER_ERRORS.OPTION_NOT_FOUND(String(selectedValue)), + { context, optionId: String(selectedValue) } + ); + return null; + } + const optionDef = catalog.options[selectedValue]; + + return optionDef.code; + } + + // ------------------------------------------------------------------------- + // basePath en cascada + // ------------------------------------------------------------------------- + + /** + * Resuelve el basePath en cascada: + * vista → sección → objeto → catálogo + */ + private resolveBasePath( + config : StaticImageConfig, + view : SectionView, + section : VisualSection, + object : ConfigurableObject, + catalog : ConfigurationCatalog + ): string { + return config.basePath + ?? section.basePath + ?? object.basePath + ?? catalog.basePath + ?? ''; + } + + // ------------------------------------------------------------------------- + // Fallback + // ------------------------------------------------------------------------- + + /** + * Devuelve la imagen de fallback. + * Busca en cascada entre las views de la sección, y si no hay ninguna + * configurada logea un warning y devuelve string vacío. + */ + private fallback( + catalog : ConfigurationCatalog, + object? : ConfigurableObject, + section?: VisualSection, + ): ResolveResult { + // Busca fallbackImage en cualquiera de las vistas de la sección + const viewFallback = section + ? Object.values(section.views) + .map(v => (v.visualConfig as StaticImageConfig).fallbackImage) + .find(Boolean) + : undefined; + + const fallbackImage = viewFallback ?? ''; + + if (!fallbackImage) { + this.logr.warn(ENGINE_CATEGORIES.TEMPLATE_RESOLVER, TEMPLATE_RESOLVER_WARNINGS.NO_FALLBACK_IMAGE(object?.id, section?.id), { + objectId : object?.id, + sectionId: section?.id, + }); + } + + return { url: fallbackImage, isFallback: true }; + } +} \ No newline at end of file diff --git a/src/libs/vice/tests/evaluator.test.ts b/src/libs/vice/tests/evaluator.test.ts new file mode 100644 index 0000000..eb77143 --- /dev/null +++ b/src/libs/vice/tests/evaluator.test.ts @@ -0,0 +1,391 @@ +/** + * ============================================================================ + * JSON LOGIC EVALUATOR — TESTS + * ============================================================================ + */ +import type { Logr } from '@/libs/logr'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { JsonLogicEvaluator } from '../engines/evaluator.engine'; + + +// ============================================================================ +// SETUP +// ============================================================================ + +function makeLogr(): Logr { + return { + debug : vi.fn(), + info : vi.fn(), + warn : vi.fn(), + error : vi.fn(), + getLogs : vi.fn(), + clear : vi.fn(), + serialize: vi.fn(), + setLevel : vi.fn(), + setMaxLogs: vi.fn(), + } as unknown as Logr; +} + +let logr : Logr; +let evaluator: JsonLogicEvaluator; + +beforeEach(() => { + logr = makeLogr(); + evaluator = new JsonLogicEvaluator(logr); +}); + +// ============================================================================ +// PRIMITIVOS +// ============================================================================ + +describe('primitivos', () => { + + it('devuelve null para null', () => { + expect(evaluator.evaluate(null, {})).toBeNull(); + }); + + it('devuelve boolean true', () => { + expect(evaluator.evaluate(true, {})).toBe(true); + }); + + it('devuelve boolean false', () => { + expect(evaluator.evaluate(false, {})).toBe(false); + }); + + it('devuelve número', () => { + expect(evaluator.evaluate(42, {})).toBe(42); + }); + + it('devuelve string', () => { + expect(evaluator.evaluate('hola', {})).toBe('hola'); + }); + + it('evalúa array de primitivos', () => { + expect(evaluator.evaluate([1, 2, 3] as any, {})).toEqual([1, 2, 3]); + }); +}); + +// ============================================================================ +// VAR — acceso a datos +// ============================================================================ + +describe('var', () => { + + it('accede a una propiedad de primer nivel', () => { + expect(evaluator.evaluate({ var: 'nombre' }, { nombre: 'Ana' })).toBe('Ana'); + }); + + it('accede a una propiedad anidada con dot notation', () => { + const data = { attributes: { at_calidad: 'op:premium' } }; + expect(evaluator.evaluate({ var: 'attributes.at_calidad' }, data)).toBe('op:premium'); + }); + + it('devuelve undefined para clave inexistente', () => { + expect(evaluator.evaluate({ var: 'no_existe' }, {})).toBeUndefined(); + }); + + it('devuelve undefined para ruta anidada inexistente', () => { + expect(evaluator.evaluate({ var: 'a.b.c' }, { a: {} })).toBeUndefined(); + }); + + it('devuelve undefined si el nodo intermedio es null', () => { + expect(evaluator.evaluate({ var: 'a.b' }, { a: null })).toBeUndefined(); + }); +}); + +// ============================================================================ +// IGUALDAD +// ============================================================================ + +describe('igualdad', () => { + + it('== devuelve true si los valores son iguales', () => { + expect(evaluator.evaluate({ '==': [1, 1] }, {})).toBe(true); + }); + + it('== devuelve false si los valores son distintos', () => { + expect(evaluator.evaluate({ '==': [1, 2] }, {})).toBe(false); + }); + + it('=== funciona igual que ==', () => { + expect(evaluator.evaluate({ '===': ['a', 'a'] }, {})).toBe(true); + }); + + it('!= devuelve true si los valores son distintos', () => { + expect(evaluator.evaluate({ '!=': [1, 2] }, {})).toBe(true); + }); + + it('!= devuelve false si los valores son iguales', () => { + expect(evaluator.evaluate({ '!=': [1, 1] }, {})).toBe(false); + }); + + it('!== funciona igual que !=', () => { + expect(evaluator.evaluate({ '!==': ['a', 'b'] }, {})).toBe(true); + }); + + it('! niega un valor truthy', () => { + expect(evaluator.evaluate({ '!': true }, {})).toBe(false); + }); + + it('! niega un valor falsy', () => { + expect(evaluator.evaluate({ '!': false }, {})).toBe(true); + }); + + it('compara variable con valor literal', () => { + const data = { attributes: { at_calidad: 'op:calidad_estandar' } }; + expect(evaluator.evaluate( + { '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_estandar'] }, + data + )).toBe(true); + }); +}); + +// ============================================================================ +// LÓGICOS +// ============================================================================ + +describe('lógicos', () => { + + it('and devuelve true si todos son true', () => { + expect(evaluator.evaluate({ 'and': [true, true, true] }, {})).toBe(true); + }); + + it('and devuelve false si alguno es false', () => { + expect(evaluator.evaluate({ 'and': [true, false, true] }, {})).toBe(false); + }); + + it('or devuelve true si alguno es true', () => { + expect(evaluator.evaluate({ 'or': [false, true, false] }, {})).toBe(true); + }); + + it('or devuelve false si todos son false', () => { + expect(evaluator.evaluate({ 'or': [false, false] }, {})).toBe(false); + }); + + it('and con variables', () => { + const data = { a: true, b: true }; + expect(evaluator.evaluate( + { 'and': [{ var: 'a' }, { var: 'b' }] }, + data + )).toBe(true); + }); + + it('or con variables — una false, otra true', () => { + const data = { a: false, b: true }; + expect(evaluator.evaluate( + { 'or': [{ var: 'a' }, { var: 'b' }] }, + data + )).toBe(true); + }); +}); + +// ============================================================================ +// COMPARACIÓN NUMÉRICA +// ============================================================================ + +describe('comparación numérica', () => { + + it('> devuelve true si mayor', () => { + expect(evaluator.evaluate({ '>': [5, 3] }, {})).toBe(true); + }); + + it('> devuelve false si igual', () => { + expect(evaluator.evaluate({ '>': [3, 3] }, {})).toBe(false); + }); + + it('>= devuelve true si igual', () => { + expect(evaluator.evaluate({ '>=': [3, 3] }, {})).toBe(true); + }); + + it('< devuelve true si menor', () => { + expect(evaluator.evaluate({ '<': [2, 5] }, {})).toBe(true); + }); + + it('<= devuelve true si igual', () => { + expect(evaluator.evaluate({ '<=': [5, 5] }, {})).toBe(true); + }); + + it('compara variable numérica', () => { + const data = { cantidad: 10 }; + expect(evaluator.evaluate({ '>': [{ var: 'cantidad' }, 5] }, data)).toBe(true); + }); +}); + +// ============================================================================ +// CONDICIONAL — if +// ============================================================================ + +describe('if', () => { + + it('devuelve rama then si condición es true', () => { + expect(evaluator.evaluate({ 'if': [true, 'si', 'no'] }, {})).toBe('si'); + }); + + it('devuelve rama else si condición es false', () => { + expect(evaluator.evaluate({ 'if': [false, 'si', 'no'] }, {})).toBe('no'); + }); + + it('devuelve null si no hay rama else y condición es false', () => { + expect(evaluator.evaluate({ 'if': [false, 'si'] }, {})).toBeNull(); + }); + + it('evalúa condición con variable', () => { + const data = { attributes: { at_calidad: 'op:calidad_lujo' } }; + expect(evaluator.evaluate( + { 'if': [ + { '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_lujo'] }, + 'es lujo', + 'no es lujo' + ]}, + data + )).toBe('es lujo'); + }); +}); + +// ============================================================================ +// ARRAYS — in +// ============================================================================ + +describe('in', () => { + + it('devuelve true si el elemento está en el array', () => { + expect(evaluator.evaluate({ 'in': ['b', ['a', 'b', 'c']] }, {})).toBe(true); + }); + + it('devuelve false si el elemento no está en el array', () => { + expect(evaluator.evaluate({ 'in': ['d', ['a', 'b', 'c']] }, {})).toBe(false); + }); + + it('funciona con variable como elemento', () => { + const data = { color: 'rojo' }; + expect(evaluator.evaluate( + { 'in': [{ var: 'color' }, ['rojo', 'azul']] }, + data + )).toBe(true); + }); +}); + +// ============================================================================ +// MATEMÁTICOS +// ============================================================================ + +describe('matemáticos', () => { + + it('+ suma múltiples valores', () => { + expect(evaluator.evaluate({ '+': [1, 2, 3] }, {})).toBe(6); + }); + + it('- resta dos valores', () => { + expect(evaluator.evaluate({ '-': [10, 3] }, {})).toBe(7); + }); + + it('- niega un valor único', () => { + expect(evaluator.evaluate({ '-': [5] }, {})).toBe(-5); + }); + + it('* multiplica', () => { + expect(evaluator.evaluate({ '*': [3, 4] }, {})).toBe(12); + }); + + it('/ divide', () => { + expect(evaluator.evaluate({ '/': [10, 2] }, {})).toBe(5); + }); + + it('/ devuelve null en división por cero y logea warning', () => { + expect(evaluator.evaluate({ '/': [10, 0] }, {})).toBeNull(); + expect(logr.warn).toHaveBeenCalledTimes(1); + }); + + it('% calcula módulo', () => { + expect(evaluator.evaluate({ '%': [10, 3] }, {})).toBe(1); + }); + + it('calcula precio dinámico con descuento', () => { + const data = { attributes: { at_calidad: 'op:calidad_lujo' } }; + const expr = { + 'if': [ + { '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_lujo'] }, + { '*': [45, 0.9] }, + 45 + ] + }; + expect(evaluator.evaluate(expr, data)).toBeCloseTo(40.5); + }); +}); + +// ============================================================================ +// OPERADOR DESCONOCIDO +// ============================================================================ + +describe('operador desconocido', () => { + + it('devuelve null y logea warning', () => { + const result = evaluator.evaluate({ 'operadorFalso': [] } as any, {}); + expect(result).toBeNull(); + expect(logr.warn).toHaveBeenCalledTimes(1); + }); +}); + +// ============================================================================ +// EXPRESIONES COMPUESTAS — casos reales del catálogo de viviendas +// ============================================================================ + +describe('expresiones del catálogo de viviendas', () => { + + it('condición FORBID — calidad estándar prohíbe mármol', () => { + const data = { attributes: { at_calidad: 'op:calidad_estandar' } }; + expect(evaluator.evaluate( + { '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_estandar'] }, + data + )).toBe(true); + }); + + it('condición FORBID — calidad premium no activa la regla', () => { + const data = { attributes: { at_calidad: 'op:calidad_premium' } }; + expect(evaluator.evaluate( + { '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_estandar'] }, + data + )).toBe(false); + }); + + it('condición ALLOW — microcemento en suelo activa restricción en baño', () => { + const data = { attributes: { at_suelo: 'op:suelo_microcemento' } }; + expect(evaluator.evaluate( + { '==': [{ var: 'attributes.at_suelo' }, 'op:suelo_microcemento'] }, + data + )).toBe(true); + }); + + it('condición con and — sanitario negro Y calidad estándar', () => { + const data = { + attributes: { + at_sanitario: 'op:sanit_negro', + at_calidad : 'op:calidad_estandar' + } + }; + expect(evaluator.evaluate( + { 'and': [ + { '==': [{ var: 'attributes.at_sanitario' }, 'op:sanit_negro'] }, + { '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_estandar'] } + ]}, + data + )).toBe(true); + }); + + it('condición con and — solo uno de los dos se cumple → false', () => { + const data = { + attributes: { + at_sanitario: 'op:sanit_blanco', + at_calidad : 'op:calidad_estandar' + } + }; + expect(evaluator.evaluate( + { 'and': [ + { '==': [{ var: 'attributes.at_sanitario' }, 'op:sanit_negro'] }, + { '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_estandar'] } + ]}, + data + )).toBe(false); + }); +}); + diff --git a/src/libs/vice/types/api.types.ts b/src/libs/vice/types/api.types.ts new file mode 100644 index 0000000..ac97779 --- /dev/null +++ b/src/libs/vice/types/api.types.ts @@ -0,0 +1,29 @@ +/** + * ============================================================================ + * API TYPES + * ============================================================================ + */ + +/** + * Método HTTP para llamadas a APIs externas de renderizado. + */ +export type APIMethod = 'GET' | 'POST'; + +/** + * Configuración de llamada a una API externa. + * Usada por la estrategia de renderizado `api_generated`. + */ +export interface APIConfig { + /** URL del endpoint */ + endpoint: string; + /** Método HTTP — GET por defecto */ + method? : APIMethod; + /** + * Parámetros a incluir en la llamada. + * El engine los resuelve desde el estado de configuración actual. + */ + params? : string[]; + /** Timeout en milisegundos */ + timeout?: number; +} + diff --git a/src/libs/vice/types/attribute.types.ts b/src/libs/vice/types/attribute.types.ts new file mode 100644 index 0000000..f40d5cb --- /dev/null +++ b/src/libs/vice/types/attribute.types.ts @@ -0,0 +1,152 @@ +/** + * ============================================================================ + * ATTRIBUTE TYPES + * ============================================================================ + */ + +import type { Code, Value, DataType } from './primitives.types'; +import type { AttrID, OptionID, SectionID } from './ids.types'; +import type { I18nString } from '@/libs/i18n'; +import type { Metadata } from './datas.types'; +import type { JsonLogic } from './json-logic.types'; + +// ============================================================================ +// ATTRIBUTE DISPLAY +// ============================================================================ + +/** + * Configuración de presentación del atributo en UI. + * Los flags pueden ser estáticos o evaluados dinámicamente con JsonLogic. + */ +export interface AttributeDisplay { + /** Si se muestra en UI */ + uiVisible? : boolean | JsonLogic; + /** + * Si el valor de este atributo afecta la imagen renderizada. + * Los atributos con affectsVisual: true son candidatos a aparecer + * como placeholders en los templates de vista. + */ + affectsVisual? : boolean | JsonLogic; + /** Si afecta al precio */ + affectsPrice? : boolean | JsonLogic; + /** Si es de solo lectura */ + readonly? : boolean; + /** Orden de visualización */ + order? : number; + /** Icono */ + icon? : string; + /** Texto de ayuda */ + helpText? : I18nString; +} + +// ============================================================================ +// ATTRIBUTE CATEGORY +// ============================================================================ + +export type AttributeCategory = + | 'physical' + | 'aesthetic' + | 'functional' + | 'structural' + | 'technical' + | 'financial'; + +// ============================================================================ +// BASE ATTRIBUTE +// ============================================================================ + +export interface BaseAttribute { + id : AttrID; + /** + * Identificador legible usado en placeholders de templates. + * Debe ser único dentro del scope (sección u objeto). + * + * @example 'suelo' | 'pintura' | 'carpinteria' + */ + code : Code; + name : I18nString; + description: I18nString; + category? : AttributeCategory; + display : AttributeDisplay; + metadata? : Metadata; +} + +// ============================================================================ +// FIXED ATTRIBUTE +// Valor fijo — no lo elige el usuario +// ============================================================================ + +export interface FixedAttribute extends BaseAttribute { + type : 'fixed'; + dataType: DataType; + value : Value; + unit? : I18nString; +} + +// ============================================================================ +// DYNAMIC ATTRIBUTE +// El usuario elige entre opciones del catálogo +// ============================================================================ + +export interface DynamicAttribute extends BaseAttribute { + type : 'dynamic'; + dataType : 'reference'; + defaultValue : OptionID; + options : AttributeOption[]; + required? : boolean; + /** + * Si este atributo controla la disponibilidad de secciones. + * Ejemplo: elegir 'con_terraza' activa la sección 'sc:terraza' + */ + controls? : SectionID[]; + filterExpression?: JsonLogic; +} + +export interface AttributeOption { + optionId : OptionID; + priority? : number; + pricingOverride?: number; + metadata? : Metadata; +} + +// ============================================================================ +// QUANTIFIABLE ATTRIBUTE +// Valor numérico con rango o conjunto de valores +// ============================================================================ + +export interface QuantifiableAttribute extends BaseAttribute { + type : 'quantifiable'; + dataType : 'number'; + quantity : QuantityDefinition; + unit? : I18nString; + userConfigurable?: boolean; + pricePerUnit? : number; +} + +export type QuantityDefinition = + | number + | { min: number; max: number; step?: number; default: number } + | { values: number[]; default: number }; + +// ============================================================================ +// COMPUTED ATTRIBUTE +// Calculado a partir de otros atributos — nunca editable por el usuario +// ============================================================================ + +export interface ComputedAttribute extends BaseAttribute { + type : 'computed'; + dataType : DataType; + unit? : I18nString; + expression : JsonLogic; + dependencies: AttrID[]; +} + +// ============================================================================ +// UNION +// ============================================================================ + +export type Attribute = + | FixedAttribute + | DynamicAttribute + | QuantifiableAttribute + | ComputedAttribute; \ No newline at end of file diff --git a/src/libs/vice/types/catalog.types.ts b/src/libs/vice/types/catalog.types.ts new file mode 100644 index 0000000..8f6197b --- /dev/null +++ b/src/libs/vice/types/catalog.types.ts @@ -0,0 +1,56 @@ +/** + * ============================================================================ + * CONFIGURATION CATALOG TYPES + * ============================================================================ + */ + +import type { Code } from './primitives.types'; +import type { CatalogID, OptionID, ObjectID, RuleID } from './ids.types'; +import type { I18nString } from '@/libs/i18n'; +import type { OptionDefinition } from './option.types'; +import type { ConfigurableObject } from './object.types'; +import type { ValidationRule } from './rule.types'; + +/** + * Catálogo de configuración. + * + * Raíz del sistema — contiene todas las opciones, objetos y reglas. + * + * El `basePath` actúa como fallback final de la cadena de herencia: + * vista → sección → objeto → catálogo + * + * La gestión de locales se delega al sistema i18n — el catálogo + * no necesita saber qué locales están activos. + */ +export interface ConfigurationCatalog { + id : CatalogID; + code : Code; + name : I18nString; + description?: I18nString; + + /** + * basePath raíz — fallback final para todas las vistas + * que no definan el suyo en ningún nivel superior. + * + * @example '/renders' + */ + basePath? : string; + + /** + * Opciones disponibles en el catálogo. + * Referenciadas por los atributos dinámicos de todo el sistema. + */ + options : Record; + + /** + * Objetos configurables del catálogo. + */ + objects : Record; + + /** + * Reglas de validación globales (opcional). + * Se evalúan sobre el estado de configuración completo. + */ + rules? : Record; +} + diff --git a/src/libs/vice/types/datas.types.ts b/src/libs/vice/types/datas.types.ts new file mode 100644 index 0000000..c82002a --- /dev/null +++ b/src/libs/vice/types/datas.types.ts @@ -0,0 +1,39 @@ +/** + * ============================================================================ + * MEDIA TYPES + * ============================================================================ + */ + +/** + * ============================================================================ + * METADATA TYPES + * ============================================================================ + */ + +/** + * Metadata flexible para extender cualquier entidad del modelo. + * Permite añadir datos arbitrarios sin modificar los tipos core. + */ +export type Metadata = Record; + + + +/** + * ============================================================================ + * MEDIA TYPES + * ============================================================================ + */ + +/** + * Recursos visuales asociados a una opción o entidad. + */ +export interface Media { + /** Imagen en miniatura para selectores y listas */ + thumbnail?: string; + /** Imagen principal */ + image? : string; + /** Icono pequeño */ + icon? : string; + /** Galería de imágenes adicionales */ + gallery? : string[]; +} diff --git a/src/libs/vice/types/hotspot.types.ts b/src/libs/vice/types/hotspot.types.ts new file mode 100644 index 0000000..ea0c0cb --- /dev/null +++ b/src/libs/vice/types/hotspot.types.ts @@ -0,0 +1,67 @@ +/** + * ============================================================================ + * HOTSPOT TYPES + * ============================================================================ + */ + +import type { AttrID, HotspotID } from './ids.types'; +import type { I18nString } from '@/libs/i18n'; +import type { JsonLogic } from './json-logic.types'; + +/** + * Punto interactivo sobre una imagen de vista. + * Permite al usuario hacer clic en una zona de la imagen para + * seleccionar o modificar el atributo asociado directamente. + * + * Las coordenadas son porcentuales (0-100) para ser independientes + * de la resolución de la imagen. + * + * @example + * // Hotspot sobre el suelo del salón + * { + * id : 'hs:suelo_salon', + * name : { es: 'Suelo', en: 'Floor' }, + * x : 50, + * y : 80, + * targetAttributeId: 'at:suelo', + * scopeType : 'section', + * } + */ +export interface Hotspot { + id : HotspotID; + name: I18nString; + + /** Posición X en porcentaje sobre la imagen (0-100) */ + x: number; + /** Posición Y en porcentaje sobre la imagen (0-100) */ + y: number; + + /** Radio del área interactiva en píxeles */ + radius?: number; + + /** Color visual del hotspot */ + color?: string; + + /** Icono del hotspot */ + icon?: string; + + /** + * Scope del atributo que controla este hotspot. + * - `global` : El atributo pertenece al objeto (`ConfigurableObject.attributes`) + * - `section` : El atributo pertenece a la sección que contiene esta vista + */ + scopeType: 'global' | 'section'; + + /** ID del atributo que este hotspot controla */ + targetAttributeId: AttrID; + + /** Visibilidad estática o condicional */ + visible?: boolean | JsonLogic; + + /** Tooltip al hacer hover */ + tooltip?: I18nString; + + /** Z-index para superposición de hotspots */ + zIndex?: number; +} + diff --git a/src/libs/vice/types/ids.types.ts b/src/libs/vice/types/ids.types.ts new file mode 100644 index 0000000..aafcf49 --- /dev/null +++ b/src/libs/vice/types/ids.types.ts @@ -0,0 +1,29 @@ +/** + * ============================================================================ + * BRANDED ID TYPES + * ============================================================================ + */ + +import type { ID } from './primitives.types'; + +export type AttrID = ID<'at'>; +export type SectionID = ID<'sc'>; +export type OptionID = ID<'op'>; +export type ObjectID = ID<'ob'>; +export type RuleID = ID<'rl'>; +export type ViewID = ID<'vw'>; +export type HotspotID = ID<'hs'>; +export type CatalogID = ID<'ct'>; + + +// ============================================================================ +// TYPE GUARDS +// ============================================================================ + +export function isOptionID(value: unknown): value is OptionID { + return typeof value === 'string' && value.startsWith('op:'); +} + +export function isAttrID(value: unknown): value is AttrID { + return typeof value === 'string' && value.startsWith('at:'); +} \ No newline at end of file diff --git a/src/libs/vice/types/index.ts b/src/libs/vice/types/index.ts new file mode 100644 index 0000000..168ea28 --- /dev/null +++ b/src/libs/vice/types/index.ts @@ -0,0 +1,16 @@ + + +export * from './primitives.types.ts'; +export * from './ids.types.ts'; +export * from './datas.types.ts'; +export * from './api.types.ts'; +export * from './attribute.types.ts'; +export * from './hotspot.types.ts'; +export * from './json-logic.types.ts'; +export * from './option.types.ts'; +export * from './pricing.types.ts'; +export * from './rule.types.ts'; +export * from './section.types.ts'; +export * from './view.types.ts'; +export * from './object.types.ts'; +export * from './catalog.types.ts'; \ No newline at end of file diff --git a/src/libs/vice/types/json-logic.types.ts b/src/libs/vice/types/json-logic.types.ts new file mode 100644 index 0000000..d851e3f --- /dev/null +++ b/src/libs/vice/types/json-logic.types.ts @@ -0,0 +1,28 @@ +/** + * ============================================================================ + * JSON LOGIC TYPES + * ============================================================================ + */ + +/** + * Expresión JsonLogic para lógica condicional declarativa. + * + * Intencionalmente permisivo — la validación estructural profunda + * se delega al engine de json-logic en runtime. + * + * Las variables referencian atributos por su ID: + * @example + * { var: 'at:calidad' } // valor del atributo at:calidad + * { '==': [{ var: 'at:calidad' }, 'op:premium'] } // comparación + * { 'and': [...] } // operador lógico + * { 'if': [...] } // condicional + */ +export type JsonLogic = + | { var: string } + | { [op: string]: JsonLogic | JsonLogic[] | unknown } + | string + | number + | boolean + | null; + + diff --git a/src/libs/vice/types/object.types.ts b/src/libs/vice/types/object.types.ts new file mode 100644 index 0000000..4324be0 --- /dev/null +++ b/src/libs/vice/types/object.types.ts @@ -0,0 +1,68 @@ +/** + * ============================================================================ + * CONFIGURABLE OBJECT TYPES + * ============================================================================ + */ +import type { I18nString } from '@/libs/i18n'; +import type { Code } from './primitives.types'; +import type { ObjectID, SectionID } from './ids.types'; +import type { Metadata } from './datas.types'; +import type { Attribute } from './attribute.types'; +import type { Section } from './section.types'; + +// ============================================================================ +// CONFIGURABLE OBJECT +// ============================================================================ + +/** + * Objeto configurable — el producto que el usuario está configurando. + * + * Ejemplos: vivienda, coche, mueble, laptop. + * + * El objeto actúa como contexto global: + * - Sus atributos aplican a todas las secciones + * - Su basePath es el fallback de todas las vistas que no definan el suyo + */ +export interface ConfigurableObject { + id : ObjectID; + code : Code; + name : I18nString; + description: I18nString; + + /** + * Atributos globales del objeto. + * Aplican independientemente de la sección activa. + * Pueden aparecer como placeholders en cualquier vista. + * + * @example 'calidad de acabados', 'color principal' + */ + attributes : Attribute[]; + + /** + * Secciones del objeto. + * + * @example { 'sc:salon': ..., 'sc:bano': ..., 'sc:dormitorio': ... } + */ + sections : Record; + + /** + * Orden de presentación de secciones en UI. + * Si no se especifica, el orden es el de inserción en `sections`. + */ + sectionOrder?: SectionID[]; + + /** + * basePath de este objeto — heredado por las secciones y vistas + * que no definan el suyo propio. + * + * @example '/renders/vivienda' + */ + basePath? : string; + + /** Categoría del objeto */ + category? : string; + + /** Metadata adicional */ + metadata? : Metadata; +} + diff --git a/src/libs/vice/types/option.types.ts b/src/libs/vice/types/option.types.ts new file mode 100644 index 0000000..855c4f5 --- /dev/null +++ b/src/libs/vice/types/option.types.ts @@ -0,0 +1,41 @@ +/** + * ============================================================================ + * OPTION DEFINITION TYPES + * ============================================================================ + */ +import type { I18nString } from '@/libs/i18n'; +import type { Code } from './primitives.types'; +import type { OptionID } from './ids.types'; +import type { Media, Metadata } from './datas.types'; +import type { Pricing } from './pricing.types'; + +/** + * Opción seleccionable para un atributo dinámico. + * + * El campo `code` es el valor que se inyecta en los templates de imagen + * cuando este atributo tiene esta opción seleccionada. + * + * @example + * // Template: '{at:suelo}_{at:pintura}.png' + * // Con suelo = op:madera → code 'madera' + * // Con pintura = op:blanco → code 'blanco' + * // Resultado: 'madera_blanco.png' + */ +export interface OptionDefinition { + id : OptionID; + + /** + * Identificador legible usado en templates de imagen. + * Sin prefijos, sin caracteres especiales. + * + * @example 'madera' | 'blanco' | 'roble' + */ + code : Code; + + name : I18nString; + description: I18nString; + media? : Media; + pricing? : Pricing; + tags? : string[]; + metadata? : Metadata; +} \ No newline at end of file diff --git a/src/libs/vice/types/pricing.types.ts b/src/libs/vice/types/pricing.types.ts new file mode 100644 index 0000000..aa4efbb --- /dev/null +++ b/src/libs/vice/types/pricing.types.ts @@ -0,0 +1,84 @@ +/** + * ============================================================================ + * PRICING TYPES + * ============================================================================ + */ + +import type { JsonLogic } from './json-logic.types'; +import type { I18nString } from '@/libs/i18n'; + +/** + * Información fiscal del precio. + * Diseñado para ser multinacional — el porcentaje es libre + * y la etiqueta permite nombrar el impuesto según el país. + * + * @example + * // España + * { included: true, rate: 0.21, label: { es: 'IVA', en: 'VAT' } } + * // Alemania + * { included: false, rate: 0.19, label: { de: 'MwSt', en: 'VAT' } } + * // Francia + * { included: true, rate: 0.20, label: { fr: 'TVA', en: 'VAT' } } + * // Tipo reducido + * { included: true, rate: 0.10, label: { es: 'IVA reducido', en: 'Reduced VAT' } } + */ +export interface TaxInfo { + /** Si el impuesto ya está incluido en `baseAmount` */ + included: boolean; + /** + * Porcentaje del impuesto expresado como decimal. + * @example 0.21 → 21% | 0.10 → 10% | 0.04 → 4% + */ + rate : number; + /** + * Nombre del impuesto según el país. + * Permite mostrar 'IVA', 'VAT', 'TVA', 'MwSt'... en la UI. + */ + label? : I18nString; +} + +/** + * Información de precio de una opción. + * + * El precio puede ser: + * - Un valor fijo (`baseAmount: number`) + * - Bajo consulta (`baseAmount: 'consultation'`) + * - Calculado dinámicamente (`dynamicExpression`) — sobreescribe `baseAmount` + * + * @example + * // Precio fijo + * { baseAmount: 45, currency: 'EUR' } + * + * // Precio dinámico con descuento por calidad + * { + * baseAmount: 45, + * currency: 'EUR', + * dynamicExpression: { + * 'if': [ + * { '==': [{ var: 'at:calidad' }, 'op:lujo'] }, + * { '*': [45, 0.9] }, + * 45 + * ] + * } + * } + */ +export interface Pricing { + /** + * Precio base. + * - `number` : Precio fijo en la moneda indicada + * - `'consultation'`: Precio bajo consulta — no se muestra valor + */ + baseAmount : number | 'consultation'; + /** Código ISO 4217 de la moneda */ + currency? : string; + /** + * Información fiscal. + * Opcional — no aplica si baseAmount es 'consultation'. + */ + tax? : TaxInfo; + /** + * Expresión JsonLogic para precio dinámico. + * Si está presente, sobreescribe `baseAmount` en runtime. + */ + dynamicExpression?: JsonLogic; +} \ No newline at end of file diff --git a/src/libs/vice/types/primitives.types.ts b/src/libs/vice/types/primitives.types.ts new file mode 100644 index 0000000..c3ae3ed --- /dev/null +++ b/src/libs/vice/types/primitives.types.ts @@ -0,0 +1,50 @@ +/** + * ============================================================================ + * CORE PRIMITIVES - Base Types + * ============================================================================ + */ + +/** + * ID con tipo branded para type-safety. + * El prefijo actúa de namespace y evita asignar IDs de distinto tipo por error. + * + * @example + * const attrId: ID<'at'> = 'at:suelo' + * const sectionId: ID<'sc'> = 'sc:salon' + */ +export type ID = `${T}:${string}`; + +/** + * Code legible para usar en templates de imagen. + * Sin prefijos, sin caracteres especiales — solo el identificador + * que el equipo de negocio quiere ver en las URLs. + * + * @example + * 'madera' | 'blanco' | 'salon' | 'frontal' + */ +export type Code = string; + +/** + * Valores que pueden tomar los atributos. + */ +export type Value = + | string + | number + | boolean + | Date + | ID + | Value[]; + +/** + * Tipos de datos soportados. + */ +export type DataType = + | 'string' + | 'number' + | 'boolean' + | 'date' + | 'datetime' + | 'currency' + | 'dimension' + | 'reference' + | 'list'; \ No newline at end of file diff --git a/src/libs/vice/types/rule.types.ts b/src/libs/vice/types/rule.types.ts new file mode 100644 index 0000000..89c0358 --- /dev/null +++ b/src/libs/vice/types/rule.types.ts @@ -0,0 +1,137 @@ +/** + * ============================================================================ + * VALIDATION RULE TYPES + * ============================================================================ + */ + +import type { AttrID, RuleID, Value } from './index'; +import type { I18nString } from '@/libs/i18n'; +import type { JsonLogic } from './json-logic.types'; + + +/** + * ============================================================================ + * SEVERITY TYPES + * ============================================================================ + */ + +/** + * Severidad de una violación de regla. + * + * - `error` : Bloquea la configuración — no se puede confirmar + * - `warning`: Permite continuar pero informa al usuario + * - `info` : Puramente informativo — usado con reglas `suggest` + */ +export type Severity = + | 'error' + | 'warning' + | 'info'; + + + +// ============================================================================ +// ACTION TYPE +// ============================================================================ + +/** + * Tipo de acción que aplica la regla sobre el atributo objetivo. + * + * - `allow` → Lista blanca: solo estos valores son válidos + * - `forbid` → Lista negra: estos valores están prohibidos + * - `require` → El atributo debe tener algún valor (values: []) + * - `suggest` → Recomienda valores sin forzar — severity siempre 'info' + */ +export type ValidationRuleActionType = + | 'allow' + | 'forbid' + | 'require' + | 'suggest'; + +// ============================================================================ +// ACTION +// ============================================================================ + +export interface ValidationRuleAction { + /** Qué hace la regla cuando su condición se cumple */ + type : ValidationRuleActionType; + /** Atributo sobre el que actúa */ + targetAttr: AttrID; + /** + * Valores afectados por la acción. + * - `allow` / `forbid` / `suggest`: lista de OptionIDs + * - `require`: array vacío [] — no importa el valor, pero debe existir + */ + values : Value[]; +} + +// ============================================================================ +// VALIDATION RULE +// ============================================================================ + +/** + * Regla de validación declarativa. + * + * Estructura: SI `condition` → ejecuta `action` sobre `action.targetAttr` + * + * Las reglas se evalúan en orden de prioridad descendente. + * Para relaciones bidireccionales se crean dos reglas independientes + * — una por cada dirección. + * + * @example + * // SI calidad === estándar → FORBID mármol en baño + * { + * id : 'rl:estandar_no_marmol', + * condition: { '==': [{ var: 'at:calidad' }, 'op:estandar'] }, + * action : { type: 'forbid', targetAttr: 'at:revest_bano', values: ['op:marmol'] }, + * affects : ['at:calidad', 'at:revest_bano'], + * priority : 9, + * severity : 'error', + * message : { es: 'El mármol no está disponible en calidad estándar' } + * } + */ +export interface ValidationRule { + id : RuleID; + name : I18nString; + + /** + * Condición JsonLogic que activa la regla. + * Las variables referencian atributos por su ID: `{ var: 'at:calidad' }` + */ + condition: JsonLogic; + + /** Acción a ejecutar cuando la condición se cumple */ + action : ValidationRuleAction; + + /** + * Prioridad de evaluación — mayor número = mayor prioridad. + * + * Rangos recomendados: + * - 10-15: Incompatibilidades técnicas críticas + * - 5-9 : Restricciones de negocio importantes + * - 1-4 : Sugerencias y recomendaciones estéticas + */ + priority : number; + + /** + * Atributos involucrados en esta regla. + * Incluye los atributos de la condición Y el targetAttr de la acción. + * + * Usado por el RuleEngine para indexar reglas por atributo — + * permite evaluar solo las reglas relevantes para un atributo + * sin recorrer todas las condiciones JsonLogic. + * + * @example ['at:calidad', 'at:revest_bano'] + */ + affects : AttrID[]; + + /** + * Severidad de la violación cuando la regla se incumple. + * - `error` : Bloquea la configuración + * - `warning`: Permite continuar pero avisa + * - `info` : Informativo — usado con `suggest` + */ + severity : Severity; + + /** Mensaje que se muestra al usuario cuando la regla se viola */ + message : I18nString; +} \ No newline at end of file diff --git a/src/libs/vice/types/section.types.ts b/src/libs/vice/types/section.types.ts new file mode 100644 index 0000000..ca90500 --- /dev/null +++ b/src/libs/vice/types/section.types.ts @@ -0,0 +1,138 @@ +/** + * ============================================================================ + * SECTION TYPES + * ============================================================================ + */ +import type { I18nString } from '@/libs/i18n'; +import type { Code } from './primitives.types'; +import type { AttrID, SectionID, ViewID } from './ids.types'; +import type { Metadata } from './datas.types'; +import type { Attribute } from './attribute.types'; +import type { JsonLogic } from './json-logic.types'; +import type { ViewType, ViewVisualConfig } from './view.types'; +import type { Hotspot } from './hotspot.types'; + +// ============================================================================ +// SECTION VIEW +// ============================================================================ + +/** + * Vista visual de una sección. + * Cada vista tiene su propia `ViewVisualConfig` — la estrategia de renderizado + * y el template son propios de cada vista porque `frontal` y `360` del mismo + * salón tienen configuraciones completamente distintas. + */ +export interface SectionView { + id : ViewID; + code : Code; + name : I18nString; + description?: I18nString; + type : ViewType; + + /** + * Configuración visual de esta vista — union discriminada por estrategia. + * El basePath hereda en cascada (vista → sección → objeto → catálogo) + * si no se especifica aquí. + */ + visualConfig: ViewVisualConfig; + + /** Hotspots interactivos sobre la imagen */ + hotspots? : Hotspot[]; + + /** Orden en el selector de vistas */ + order? : number; + + /** Icono para el selector de vistas */ + icon? : string; + + /** Visibilidad estática o condicional */ + visible? : boolean | JsonLogic; +} + +// ============================================================================ +// SECTION AVAILABILITY +// ============================================================================ + +/** + * Disponibilidad y comportamiento de activación/desactivación de la sección. + */ +export interface SectionAvailability { + mode : 'required' | 'optional' | 'conditional'; + /** Condición JsonLogic — solo si mode es 'conditional' */ + condition?: JsonLogic; + /** Atributos de los que depende esta sección */ + dependsOn?: AttrID[]; + /** Qué ocurre con los valores cuando se desactiva la sección */ + onDeactivate?: { + action : 'clear' | 'preserve' | 'reset'; + confirmWithUser?: boolean; + confirmMessage? : I18nString; + }; +} + +// ============================================================================ +// SECTION — union discriminada por presencia de vistas +// +// La invariante "si hay views debe haber defaultView" se expresa en tipos, +// no solo en comentarios. TypeScript la valida en compilación. +// ============================================================================ + +/** + * Sección con visualización. + * DEBE tener al menos una vista y una vista por defecto. + * Genera y muestra imágenes propias según la vista activa. + */ +export interface VisualSection { + kind : 'visual'; + id : SectionID; + code : Code; + name : I18nString; + description : I18nString; + attributes : Attribute[]; + availability: SectionAvailability; + + /** Vistas disponibles — al menos una */ + views : Record; + + /** + * Vista activa por defecto. + * DEBE existir como clave en `views`. + */ + defaultView : ViewID; + + /** basePath de esta sección — heredado por las vistas si no lo definen */ + basePath? : string; + + order? : number; + icon? : string; + metadata? : Metadata; +} + +/** + * Sección sin visualización. + * Solo configura opciones — no genera ni muestra imágenes. + * Ejemplos: garantía, seguros, servicios adicionales. + */ +export interface ConfigSection { + kind : 'config'; + id : SectionID; + code : Code; + name : I18nString; + description : I18nString; + attributes : Attribute[]; + availability: SectionAvailability; + order? : number; + icon? : string; + metadata? : Metadata; +} + +/** + * Union discriminada de secciones. + * Usa `section.kind` para distinguir en runtime. + * + * @example + * if (section.kind === 'visual') { + * // section.views y section.defaultView están disponibles + * } + */ +export type Section = VisualSection | ConfigSection; \ No newline at end of file diff --git a/src/libs/vice/types/view.types.ts b/src/libs/vice/types/view.types.ts new file mode 100644 index 0000000..ecf108a --- /dev/null +++ b/src/libs/vice/types/view.types.ts @@ -0,0 +1,143 @@ +/** + * ============================================================================ + * VIEW TYPES + * ============================================================================ + */ + +import type { AttrID } from './ids.types'; +import type { APIConfig } from './api.types'; + +// ============================================================================ +// VIEW TYPE +// ============================================================================ + +export type ViewType = + | 'front' // Vista frontal + | 'side' // Vista lateral + | 'rear' // Vista posterior + | 'top' // Vista desde arriba + | 'isometric' // Vista isométrica + | 'panoramic' // Vista 360° + | 'detail' // Vista de detalle/zoom + | 'blueprint' // Plano técnico + | 'custom'; // Tipo personalizado + +// ============================================================================ +// VIEW VISUAL CONFIG — union discriminada por estrategia +// ============================================================================ + +/** + * Configuración visual de una vista. + * Cada estrategia tiene exactamente los campos que necesita. + * + * El `basePath` en cada estrategia es un override local. + * Si no se especifica, el sistema hereda en cascada: + * vista → sección → objeto → catálogo + */ +export type ViewVisualConfig = + | StaticImageConfig + | CompositeLayersConfig + | ApiGeneratedConfig + | ThreeDConfig; + +// ============================================================================ +// STATIC IMAGE +// La URL se construye combinando basePath + template resuelto. +// +// Template placeholders: +// {at:} → code de la opción seleccionada para ese atributo +// +// @example +// basePath: '/renders/vivienda/salon' +// template: '{at:suelo}_{at:pintura}.png' +// → '/renders/vivienda/salon/madera_blanco.png' +// ============================================================================ + +export interface StaticImageConfig { + strategy: 'static_image'; + + /** + * Template de nombre de fichero. + * Solo contiene placeholders de atributos visualizables. + * El sistema resuelve cada {at:} al code de la opción activa. + * + * @example '{at:suelo}_{at:pintura}.png' + */ + template : string; + + /** Override local de basePath. Hereda del catálogo si no se especifica. */ + basePath? : string; + + /** Imagen de fallback si la URL generada no existe */ + fallbackImage?: string; +} + +// ============================================================================ +// COMPOSITE LAYERS +// La imagen se compone superponiendo capas PNG. +// Cada capa se activa/desactiva según el valor del atributo asociado. +// ============================================================================ + +export interface CompositeLayersConfig { + strategy: 'composite_layers'; + + /** Capas en orden de renderizado (de abajo a arriba) */ + layers : LayerDefinition[]; + + /** Override local de basePath */ + basePath? : string; + + /** Imagen de fallback */ + fallbackImage?: string; +} + +/** + * Definición de una capa de composición. + * El template funciona igual que en StaticImageConfig. + */ +export interface LayerDefinition { + /** Atributo que controla esta capa */ + attrId : AttrID; + + /** + * Template del fichero de capa. + * @example 'suelo_{at:suelo}.png' + */ + template: string; + + /** Orden z-index de la capa */ + zIndex? : number; + + /** Si la capa puede estar ausente (atributo no seleccionado) */ + optional?: boolean; +} + +// ============================================================================ +// API GENERATED +// La imagen se genera llamando a un servicio externo con el estado actual. +// ============================================================================ + +export interface ApiGeneratedConfig { + strategy : 'api_generated'; + apiConfig : APIConfig; + /** Imagen de fallback si la API falla o tarda demasiado */ + fallbackImage?: string; +} + +// ============================================================================ +// THREE D +// Modelo 3D en navegador con texturas/materiales dinámicos. +// ============================================================================ + +export interface ThreeDConfig { + strategy : 'three_d'; + modelUrl : string; + /** + * Atributos que controlan texturas o materiales del modelo. + * scope indica si el atributo es global del objeto o de la sección. + */ + textureAttrs: Array<{ + attrId: AttrID; + scope : 'global' | 'section'; + }>; +} \ No newline at end of file