parent
669f6dd94d
commit
70655bbffc
@ -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';
|
||||
|
||||
|
||||
// ==============================
|
||||
@ -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';
|
||||
|
||||
@ -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: ');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,78 +0,0 @@
|
||||
// i18n.ts
|
||||
import type {
|
||||
DotNestedKeys,
|
||||
GetTypeAtPath,
|
||||
InterpolationParams,
|
||||
TranslationStore
|
||||
} from './i18n-types';
|
||||
|
||||
export function createI18n<TSchema extends object, TLocale extends string>(
|
||||
initialStore: Partial<TranslationStore<TSchema, TLocale>> = {}, // Ahora puede empezar vacío
|
||||
defaultLocale: TLocale
|
||||
) {
|
||||
let currentLocale: TLocale = defaultLocale;
|
||||
|
||||
// El almacén plano donde se acumulará todo
|
||||
const flatStore = {} as Record<TLocale, Record<string, any>>;
|
||||
|
||||
function flatten(obj: any, prefix = ''): Record<string, any> {
|
||||
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: <K extends DotNestedKeys<TSchema>>(
|
||||
key: K,
|
||||
args?: GetTypeAtPath<TSchema, K> 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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> = 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> = T extends object
|
||||
? {
|
||||
[K in keyof T & string]: IsTerminal<T[K]> extends true
|
||||
? K
|
||||
: `${K}.${DotNestedKeys<T[K]>}`
|
||||
}[keyof T & string]
|
||||
: '';
|
||||
|
||||
/**
|
||||
* Extrae el tipo exacto (String o Función) de una ruta específica
|
||||
*/
|
||||
export type GetTypeAtPath<T, Path extends string> = Path extends `${infer Head}.${infer Tail}`
|
||||
? Head extends keyof T ? GetTypeAtPath<T[Head], Tail> : never
|
||||
: Path extends keyof T ? T[Path] : never;
|
||||
|
||||
/**
|
||||
* Estructura para variables de interpolación {{var}}
|
||||
*/
|
||||
export type InterpolationParams = Record<string, string | number>;
|
||||
|
||||
/**
|
||||
* Definición del almacén de traducciones
|
||||
*/
|
||||
export type TranslationStore<TSchema, TLocale extends string> = {
|
||||
[L in TLocale]: TSchema;
|
||||
};
|
||||
@ -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<TranslationStore<AppSchema, SupportedLocales>> = {
|
||||
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<AppSchema, SupportedLocales>(
|
||||
initialTranslations,
|
||||
'es'
|
||||
);
|
||||
|
||||
// Helper para exportar directamente la función de traducción
|
||||
export const t = i18n.t;
|
||||
@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -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<TranslationSchema>,
|
||||
TType = GetTypeAtPath<TranslationSchema, P>
|
||||
>(
|
||||
path: P,
|
||||
...args: HasParams<TType> extends true
|
||||
? [params: ParamsFor<TType>]
|
||||
: []
|
||||
): 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
|
||||
);
|
||||
}
|
||||
@ -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<L extends string> = {
|
||||
[K in L]?: string;
|
||||
} & {
|
||||
[K in DefaultLocale]: string;
|
||||
};
|
||||
|
||||
export type TranslationValue<L extends string> =
|
||||
| LocaleRecord<L>
|
||||
| ((params: any) => LocaleRecord<L>);
|
||||
|
||||
// ==============================
|
||||
// TYPE UTILITIES
|
||||
// ==============================
|
||||
|
||||
type DotPrefix<T extends string> = T extends '' ? '' : `.${T}`;
|
||||
|
||||
type Prev = [never, 0, 1, 2, 3, 4, 5, 6];
|
||||
|
||||
type Join<K, P> =
|
||||
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<K, Paths<T[K], Prev[D]>>
|
||||
}[keyof T & string]
|
||||
: never;
|
||||
|
||||
|
||||
export type GetTypeAtPath<
|
||||
T,
|
||||
P extends string
|
||||
> =
|
||||
P extends `${infer K}.${infer Rest}`
|
||||
? K extends keyof T
|
||||
? GetTypeAtPath<T[K], Rest>
|
||||
: never
|
||||
: P extends keyof T
|
||||
? T[P]
|
||||
: never;
|
||||
|
||||
|
||||
export type ParamsFor<T> =
|
||||
T extends (params: infer P) => any
|
||||
? P
|
||||
: never;
|
||||
|
||||
export type HasParams<T> =
|
||||
T extends (params: any) => any
|
||||
? true
|
||||
: false;
|
||||
@ -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<string, any>;
|
||||
|
||||
export type TranslationSchema = typeof translations;
|
||||
@ -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;
|
||||
@ -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<string, unknown>): 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<string, unknown>;
|
||||
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<string, unknown>): unknown[] {
|
||||
return (Array.isArray(args) ? args : [args]).map(arg =>
|
||||
this.evaluate(arg as JsonLogic, data)
|
||||
);
|
||||
}
|
||||
|
||||
private getVar(path: unknown, data: Record<string, unknown>): unknown {
|
||||
const resolvedPath = typeof path === 'string'
|
||||
? path
|
||||
: String(this.evaluate(path as JsonLogic, data));
|
||||
|
||||
return resolvedPath
|
||||
.split('.')
|
||||
.reduce<unknown>((current, part) => {
|
||||
if (current === undefined || current === null) return undefined;
|
||||
return (current as Record<string, unknown>)[part];
|
||||
}, data);
|
||||
}
|
||||
}
|
||||
@ -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<AttrID, Value>;
|
||||
|
||||
/**
|
||||
* 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<AttrID, AttributeRuleResult> {
|
||||
const results = new Map<AttrID, AttributeRuleResult>();
|
||||
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<string, Value> {
|
||||
const normalized: Record<string, Value> = {};
|
||||
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);
|
||||
@ -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:<code>} 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:<code>} 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:<code>} 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:<attrCode>} 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 };
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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<OptionID, OptionDefinition>;
|
||||
|
||||
/**
|
||||
* Objetos configurables del catálogo.
|
||||
*/
|
||||
objects : Record<ObjectID, ConfigurableObject>;
|
||||
|
||||
/**
|
||||
* Reglas de validación globales (opcional).
|
||||
* Se evalúan sobre el estado de configuración completo.
|
||||
*/
|
||||
rules? : Record<RuleID, ValidationRule>;
|
||||
}
|
||||
|
||||
@ -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<string, unknown>;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* 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[];
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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:');
|
||||
}
|
||||
@ -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';
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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<SectionID, Section>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 extends string = string> = `${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';
|
||||
@ -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;
|
||||
}
|
||||
@ -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<ViewID, SectionView>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@ -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:<attrCode>} → 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:<code>} 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';
|
||||
}>;
|
||||
}
|
||||
Loading…
Reference in new issue