From 9bf050f990f8f5f591adca4b25323a7da3642cf5 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 20 Feb 2026 13:11:26 +0100 Subject: [PATCH] svelte adapters first aprox --- readme/ARCHITECTURE.md | 325 +++++++ readme/model.md | 813 ++++++++++++++++++ readme/readme.md | 258 ++++++ readme/views.md | 338 ++++++++ .../svelte/components/Configurator.svelte | 346 ++++++++ src/adapters/svelte/stores/configuration.ts | 197 +++++ src/messages/validation-section.ts | 90 ++ src/state/ConfigurationState.ts | 377 ++++++++ src/state/SelectionState.ts | 206 +++++ src/state/ValidationState.ts | 176 ++++ src/types/model/object.types.ts | 131 ++- src/types/model/section.types.ts | 152 ++-- src/types/view/view.types.ts | 1 - src/utils/validation/section.validator.ts | 384 ++++++++- tsconfig.json | 12 +- 15 files changed, 3705 insertions(+), 101 deletions(-) create mode 100644 readme/ARCHITECTURE.md create mode 100644 readme/model.md create mode 100644 readme/readme.md create mode 100644 readme/views.md create mode 100644 src/adapters/svelte/components/Configurator.svelte create mode 100644 src/adapters/svelte/stores/configuration.ts create mode 100644 src/messages/validation-section.ts create mode 100644 src/state/ConfigurationState.ts create mode 100644 src/state/SelectionState.ts create mode 100644 src/state/ValidationState.ts diff --git a/readme/ARCHITECTURE.md b/readme/ARCHITECTURE.md new file mode 100644 index 0000000..0572531 --- /dev/null +++ b/readme/ARCHITECTURE.md @@ -0,0 +1,325 @@ +# Arquitectura Reactiva - Documentación + +## 🏗️ Estructura Híbrida + +La arquitectura separa **estado agnóstico del framework** de los **adapters específicos**: + +``` +src/ +├── state/ # ← Estado puro (sin framework) +│ ├── SelectionState.ts +│ ├── ValidationState.ts +│ └── ConfigurationState.ts +│ +└── adapters/ # ← Adapters por framework + └── svelte/ + ├── stores/ # Wrappers Svelte + └── components/ # Componentes UI +``` + +--- + +## 📦 Capa 1: Estado Agnóstico (`/state`) + +### `SelectionState.ts` + +Gestiona qué opciones están seleccionadas. + +**Responsabilidades:** +- Almacenar selecciones: `Record` +- Notificar cambios via listeners +- Historial de cambios (undo/redo) +- Persistencia (toJSON/fromJSON) + +**API:** +```typescript +const selection = new SelectionState(); + +// Establecer valor +selection.set('at:suelo', 'op:parquet'); + +// Obtener valor +selection.get('at:suelo'); // → 'op:parquet' + +// Limpiar +selection.clear('at:suelo'); + +// Suscribirse a cambios +const unsubscribe = selection.subscribe((event) => { + console.log(`${event.attrId} cambió a ${event.newValue}`); +}); + +// Deshacer +selection.undo(); +``` + +--- + +### `ValidationState.ts` + +Gestiona validaciones y violaciones usando el `RuleEngine`. + +**Responsabilidades:** +- Validar selecciones contra reglas +- Detectar violaciones (errores/warnings) +- Verificar valores permitidos/prohibidos +- Determinar atributos requeridos + +**API:** +```typescript +const validation = new ValidationState(); + +// Configurar reglas +validation.setRules(catalog.rules); + +// Validar estado +const result = validation.validate(selectionState); + +console.log(result.isValid); // → boolean +console.log(result.violations); // → Violation[] + +// Helpers +validation.isValueAllowed('at:suelo', 'op:marmol', state); +validation.getAllowedValues('at:suelo', state); +validation.isRequired('at:material', state); +``` + +--- + +### `ConfigurationState.ts` + +**Orquestador principal** que coordina todo. + +**Responsabilidades:** +- Gestionar catálogo y objeto +- Coordinar SelectionState + ValidationState +- Navegación (sección/vista actual) +- Auto-validación en cambios +- Persistencia completa + +**API:** +```typescript +const config = new ConfigurationState({ + catalog: CATALOGO_VIVIENDAS, + objectId: 'ob:apartamento', + autoValidate: true +}); + +// Selección +config.selectOption('at:suelo', 'op:parquet'); +config.getSelection('at:suelo'); + +// Navegación +config.setCurrentSection('sc:salon'); +config.setCurrentView('vw:front'); + +// Validación +config.validate(); +config.isValid(); +config.getViolationsForAttribute('at:suelo'); + +// Helpers +config.getAllowedValues('at:suelo'); +config.isRequired('at:material'); + +// Persistencia +const saved = config.toJSON(); +config.fromJSON(saved); + +// Utils +config.reset(); +config.undo(); +``` + +--- + +## 🔌 Capa 2: Adapters Svelte (`/adapters/svelte`) + +### `stores/configuration.ts` + +Wrapper que convierte `ConfigurationState` en Svelte store. + +**Características:** +- ✅ Reactivo automático (subscribe) +- ✅ API idéntica a ConfigurationState +- ✅ Derived stores para valores específicos +- ✅ Type-safe + +**Uso:** +```typescript +import { createConfigurationStore } from '@/adapters/svelte/stores'; + +const config = createConfigurationStore({ + catalog: CATALOGO_VIVIENDAS, + objectId: 'ob:apartamento' +}); + +// En componentes Svelte +$: selection = $config.selection; +$: isValid = $config.isValid; +$: currentSection = $config.currentSection; + +// Acciones +config.selectOption('at:suelo', 'op:parquet'); +config.setCurrentSection('sc:salon'); +``` + +**Derived stores:** +```typescript +const currentSection = deriveCurrentSection(config); +const validation = deriveValidation(config); +const isValid = deriveIsValid(config); +``` + +--- + +## 🎨 Capa 3: Componentes UI (`/adapters/svelte/components`) + +### `Configurator.svelte` + +Componente principal de ejemplo. + +**Features:** +- ✅ Navegación de secciones +- ✅ Selector de vistas +- ✅ Panel de atributos +- ✅ Preview visual +- ✅ Validación en tiempo real +- ✅ Undo/Reset + +**Uso:** +```svelte + + + +``` + +--- + +## 🔄 Flujo de Datos + +``` +User Action (click) + ↓ +Component Handler + ↓ +Store Action (config.selectOption) + ↓ +ConfigurationState + ↓ +SelectionState.set() + ↓ +Notifica listeners + ↓ +ValidationState.validate() (si autoValidate) + ↓ +Notifica cambio + ↓ +Svelte Store actualiza + ↓ +UI re-renderiza (reactive $config) +``` + +--- + +## ✅ Ventajas de esta arquitectura + +### 1. **Separación de concerns** +- Estado = Lógica de negocio (testeable sin UI) +- Adapter = Integración con framework +- Components = Solo presentación + +### 2. **Framework agnostic** +El 90% del código está en `/state` y no depende de Svelte. + +Añadir React es fácil: +``` +/adapters/react/ + └── hooks/ + └── useConfiguration.ts ← Wrapper con useState/useEffect +``` + +### 3. **Testing simple** +```typescript +// Test estado SIN montar componentes +const config = new ConfigurationState({...}); +config.selectOption('at:suelo', 'op:parquet'); +expect(config.getSelection('at:suelo')).toBe('op:parquet'); +``` + +### 4. **Type-safety completo** +Todo tipado con TypeScript. El editor te ayuda en cada paso. + +### 5. **Reactivo por defecto** +Cambias el estado → UI se actualiza automáticamente. + +--- + +## 📝 Ejemplo completo + +```svelte + + + +
+

{$config.object.name.es}

+ + + + {#if !isValid} +
+ {#each violations as v} +

{v.rule.message.es}

+ {/each} +
+ {/if} + + +
+``` + +--- + +## 🚀 Próximos pasos + +1. ✅ Estado agnóstico implementado +2. ✅ Adapter Svelte implementado +3. ✅ Componente de ejemplo creado +4. ⏳ Implementar renderizado de imágenes +5. ⏳ Añadir precio dinámico +6. ⏳ Implementar más componentes (AttributePanel, ValidationPanel, etc.) +7. ⏳ Testing de stores +8. ⏳ Documentación de componentes + +--- + +## 📚 Archivos clave + +- `/state/ConfigurationState.ts` - Orquestador principal +- `/adapters/svelte/stores/configuration.ts` - Svelte store +- `/adapters/svelte/components/Configurator.svelte` - Componente ejemplo +- `/docs/ARCHITECTURE.md` - Este archivo diff --git a/readme/model.md b/readme/model.md new file mode 100644 index 0000000..f1a4a8b --- /dev/null +++ b/readme/model.md @@ -0,0 +1,813 @@ +# Modelo de datos - Documentación completa + +## 📋 Tabla de contenidos + +- [Visión general](#visión-general) +- [Catálogo](#catálogo) +- [Objetos configurables](#objetos-configurables) +- [Secciones](#secciones) +- [Atributos](#atributos) +- [Opciones](#opciones) +- [Reglas de validación](#reglas-de-validación) +- [Ejemplo completo](#ejemplo-completo) + +--- + +## Visión general + +El modelo CPQ está organizado jerárquicamente: + +``` +ConfigurationCatalog +└── Objects (ConfigurableObject) + ├── Attributes (global) + └── Sections + └── Attributes (per-section) + └── Options + +Rules (aplican a cualquier combinación) +``` + +--- + +## Catálogo + +### `ConfigurationCatalog` + +Contenedor principal de todo el modelo. + +```typescript +interface ConfigurationCatalog { + name : I18nString; + description? : I18nString; + supportedLocales: string[]; + defaultLocale : string; + options : Record; + objects : Record; + rules? : Record; +} +``` + +**Ejemplo:** + +```typescript +const CATALOGO_VIVIENDAS: ConfigurationCatalog = { + name: { + es: 'Catálogo de Viviendas', + en: 'Housing Catalog' + }, + description: { + es: 'Configurador de acabados', + en: 'Finish configurator' + }, + supportedLocales: ['es', 'en'], + defaultLocale: 'es', + options: { /* ... */ }, + objects: { /* ... */ }, + rules: { /* ... */ } +}; +``` + +--- + +## Objetos configurables + +### `ConfigurableObject` + +Representa un producto/objeto que puede configurarse (ej: casa, coche, mueble). + +```typescript +interface ConfigurableObject { + id : ObjectID; // 'ob:apartamento' + name : I18nString; + description : I18nString; + attributes : Attribute[]; // Atributos GLOBALES + sections : Record; + sectionOrder?: SectionID[]; + category? : string; + metadata? : Metadata; +} +``` + +### Atributos globales vs de sección + +- **Globales**: Afectan a TODO el objeto (ej: calidad general, color principal) +- **De sección**: Solo afectan a una sección específica (ej: suelo del salón) + +**Ejemplo:** + +```typescript +const APARTAMENTO: ConfigurableObject = { + id: 'ob:apartamento', + name: { es: 'Apartamento', en: 'Apartment' }, + description: { es: 'Apartamento de 1 dormitorio', en: '1 bedroom apartment' }, + + // Atributos GLOBALES (afectan a todo) + attributes: [ + { + id: 'at:calidad', + name: { es: 'Calidad de acabados', en: 'Finish quality' }, + type: 'dynamic', + dataType: 'reference', + defaultValue: 'op:calidad_estandar', + options: [ + { optionId: 'op:calidad_estandar' }, + { optionId: 'op:calidad_premium' }, + { optionId: 'op:calidad_lujo' } + ], + isController: true, // ✅ Controla visibilidad/precios globalmente + display: { + uiVisible: true, + affectsPrice: true + } + } + ], + + // Secciones del objeto + sections: { + 'sc:salon': { /* ... */ }, + 'sc:bano': { /* ... */ } + }, + + sectionOrder: ['sc:salon', 'sc:bano'] +}; +``` + +--- + +## Secciones + +### `Section` + +Representa una parte del objeto configurable (ej: salón, baño, motor). + +```typescript +interface Section { + id : SectionID; + name : I18nString; + description : I18nString; + attrs : Attribute[]; // Atributos DE ESTA SECCIÓN + visualConfig: SectionVisualConfig; + availability: SectionAvailability; + order? : number; + icon? : string; + metadata? : Metadata; +} +``` + +### Visual Config + +Controla cómo se renderiza visualmente la sección: + +```typescript +interface SectionVisualConfig { + strategy: RenderingStrategy; + + // Para 'static_image' o 'dynamic_image' + imageUrlTemplate? : string; + globalAttrDependencies? : AttrID[]; + sectionAttrDependencies?: AttrID[]; + basePath? : string; + + // Para 'api_generated' + apiConfig?: APIConfig; + + // Fallback + fallbackImage?: string; +} +``` + +**Estrategias de renderizado:** + +| Estrategia | Uso | +|------------|-----| +| `static_image` | URL con placeholders: `{basePath}/salon/{at_suelo}/{at_pared}.jpg` | +| `dynamic_image` | URL dinámica calculada | +| `api_generated` | Llamada a API externa para generar imagen | +| `three_d` | Modelo 3D con texturas dinámicas | +| `composite_layers` | Capas PNG superpuestas | +| `none` | Sin renderizado visual | + +### Availability + +Controla cuándo la sección está disponible: + +```typescript +interface SectionAvailability { + mode: 'required' | 'optional' | 'conditional'; + + // Si mode es 'conditional' + condition?: JsonLogic; + dependsOn?: AttrID[]; + + // Qué hacer al desactivarse + onDeactivate?: { + action: 'clear' | 'preserve' | 'reset'; + confirmWithUser?: boolean; + confirmMessage?: I18nString; + }; +} +``` + +**Ejemplo de sección:** + +```typescript +const seccionSalon: Section = { + id: 'sc:salon', + name: { es: 'Salón', en: 'Living room' }, + description: { es: 'Salón - comedor principal', en: 'Main living - dining room' }, + + // Atributos DE ESTA SECCIÓN + attrs: [ + { + id: 'at:suelo', + name: { es: 'Suelo', en: 'Flooring' }, + type: 'dynamic', + dataType: 'reference', + defaultValue: 'op:suelo_ceramica', + options: [ + { optionId: 'op:suelo_ceramica' }, + { optionId: 'op:suelo_parquet' } + ], + display: { + uiVisible: true, + affectsVisual: true, // ✅ Afecta al render + affectsPrice: true + } + }, + { + id: 'at:pared', + name: { es: 'Color paredes', en: 'Wall color' }, + type: 'dynamic', + dataType: 'reference', + defaultValue: 'op:pared_blanco', + options: [ /* ... */ ], + display: { uiVisible: true, affectsVisual: true } + } + ], + + // Configuración visual + visualConfig: { + strategy: 'static_image', + imageUrlTemplate: '{basePath}/salon/{at_suelo}/{at_pared}/view.jpg', + basePath: '/renders/vivienda', + + // Atributos GLOBALES que afectan esta imagen + globalAttrDependencies: ['at:calidad'], + + // Atributos DE SECCIÓN que afectan + sectionAttrDependencies: ['at:suelo', 'at:pared'] + }, + + // Disponibilidad + availability: { + mode: 'required' // Siempre visible + } +}; +``` + +--- + +## Atributos + +### Tipos de atributos + +```typescript +type Attribute = + | FixedAttribute + | DynamicAttribute + | QuantifiableAttribute + | ComputedAttribute; +``` + +### 1. Fixed Attribute + +Valor fijo, no modificable por el usuario. + +```typescript +interface FixedAttribute { + type : 'fixed'; + dataType: DataType; + value : Value; + unit? : I18nString; + display : AttributeDisplay; +} +``` + +**Ejemplo:** + +```typescript +{ + id: 'at:m2_salon', + name: { es: 'Superficie', en: 'Surface' }, + type: 'fixed', + dataType: 'number', + value: 25, + unit: { es: 'm²', en: 'sqm' }, + display: { + uiVisible: false, // ❌ No se muestra al usuario + readonly: true + } +} +``` + +### 2. Dynamic Attribute + +Seleccionable por el usuario entre opciones. + +```typescript +interface DynamicAttribute { + type : 'dynamic'; + dataType : 'reference'; + defaultValue : OptionID; + options : AttributeOption[]; + required? : boolean; + isController? : boolean; + controls? : SectionID[]; + filterExpression?: JsonLogic; + display : AttributeDisplay; +} +``` + +**Opciones:** + +```typescript +interface AttributeOption { + optionId : OptionID; + priority? : number; // Orden de presentación + pricingOverride?: number; // Precio custom para esta combinación + metadata? : Metadata; +} +``` + +**Ejemplo:** + +```typescript +{ + id: 'at:calidad', + name: { es: 'Calidad de acabados', en: 'Finish quality' }, + type: 'dynamic', + dataType: 'reference', + defaultValue: 'op:calidad_estandar', + options: [ + { optionId: 'op:calidad_estandar', priority: 1 }, + { optionId: 'op:calidad_premium', priority: 2 }, + { optionId: 'op:calidad_lujo', priority: 3 } + ], + required: true, + isController: true, // ✅ Controla otras secciones/atributos + display: { + uiVisible: true, + affectsPrice: true, + affectsVisual: true + } +} +``` + +### 3. Quantifiable Attribute + +Valor numérico configurable (cantidad, tamaño). + +```typescript +interface QuantifiableAttribute { + type : 'quantifiable'; + dataType : 'number'; + quantity : QuantityDefinition; + unit? : I18nString; + userConfigurable?: boolean; + pricePerUnit? : number; + min? : number; + max? : number; + display : AttributeDisplay; +} + +type QuantityDefinition = + | number // Fijo + | { min: number; max: number; step?: number; default: number } // Rango + | { values: number[]; default: number }; // Lista discreta +``` + +**Ejemplo:** + +```typescript +{ + id: 'at:cantidad_lamparas', + name: { es: 'Número de lámparas', en: 'Number of lamps' }, + type: 'quantifiable', + dataType: 'number', + quantity: { + min: 1, + max: 10, + step: 1, + default: 3 + }, + unit: { es: 'uds', en: 'units' }, + userConfigurable: true, + pricePerUnit: 150, // 150€ por lámpara + display: { + uiVisible: true, + affectsPrice: true + } +} +``` + +### 4. Computed Attribute + +Valor calculado dinámicamente basado en otros atributos. + +```typescript +interface ComputedAttribute { + type : 'computed'; + dataType : DataType; + unit? : I18nString; + expression : JsonLogic; + dependencies: AttrID[]; + display : AttributeDisplay; +} +``` + +**Ejemplo:** + +```typescript +{ + id: 'at:precio_total', + name: { es: 'Precio total', en: 'Total price' }, + type: 'computed', + dataType: 'number', + unit: { es: '€', en: '€' }, + expression: { + '+': [ + { var: 'attributes.at_precio_base' }, + { var: 'attributes.at_extras' } + ] + }, + dependencies: ['at:precio_base', 'at:extras'], + display: { + uiVisible: true, + readonly: true, // ✅ Siempre readonly + affectsPrice: false // Ya ES el precio + } +} +``` + +### Display Config + +Controla cómo y cuándo se muestra un atributo: + +```typescript +interface AttributeDisplay { + uiVisible? : boolean | JsonLogic; // ¿Se muestra? + affectsVisual? : boolean | JsonLogic; // ¿Afecta al render? + affectsPrice? : boolean | JsonLogic; // ¿Afecta al precio? + readonly? : boolean; // ¿Solo lectura? + order? : number; // Orden en UI + icon? : string; + helpText? : I18nString; +} +``` + +**Visibilidad condicional:** + +```typescript +{ + display: { + uiVisible: { + '==': [{ var: 'attributes.at_tipo' }, 'avanzado'] + }, + affectsVisual: true + } +} +``` + +--- + +## Opciones + +### `OptionDefinition` + +Define una opción seleccionable (valor concreto de un atributo). + +```typescript +interface OptionDefinition { + id : OptionID; + name : I18nString; + description : I18nString; + media? : Media; + pricing? : Pricing; + tags? : string[]; + style? : string; + metadata? : Metadata; +} +``` + +### Pricing + +```typescript +interface Pricing { + baseAmount: number | 'consultation'; + currency?: string; + dynamicExpression?: JsonLogic; +} +``` + +**Ejemplo:** + +```typescript +{ + id: 'op:suelo_parquet', + name: { es: 'Parquet', en: 'Parquet' }, + description: { + es: 'Suelo de madera natural', + en: 'Natural wood floor' + }, + pricing: { + baseAmount: 45, // €/m² + currency: 'EUR', + dynamicExpression: { + // Descuento si calidad es lujo + 'if': [ + { '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_lujo'] }, + { '*': [45, 0.9] }, // 10% descuento + 45 + ] + } + }, + tags: ['madera', 'calido', 'natural'], + media: { + thumbnail: '/images/parquet-thumb.jpg', + images: ['/images/parquet-1.jpg', '/images/parquet-2.jpg'] + } +} +``` + +--- + +## Reglas de validación + +### `ValidationRule` + +Define restricciones y dependencias entre atributos. + +```typescript +interface ValidationRule { + id : RuleID; + name : I18nString; + condition : JsonLogic; // Cuándo se activa + action : ValidationRuleAction; // Qué hace + priority : number; // Mayor = más importante + bidirectional: boolean; + affects : AttrID[]; // Atributos involucrados + severity : Severity; // 'error' | 'warning' | 'info' + message : I18nString; +} +``` + +### Tipos de acción + +```typescript +type ValidationRuleActionType = + | 'allow' // Lista blanca de valores permitidos + | 'forbid' // Prohíbe valores específicos + | 'require' // Hace obligatorio un atributo + | 'suggest'; // Sugiere valores (no fuerza) + +interface ValidationRuleAction { + type : ValidationRuleActionType; + targetAttr : AttrID; + values : Value[]; +} +``` + +### Ejemplos de reglas + +#### 1. Regla FORBID + +```typescript +{ + id: 'rl:estandar_no_lujo', + name: { + es: 'Estándar sin lujo', + en: 'Standard without luxury' + }, + + // CUANDO: calidad es estándar + condition: { + '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_estandar'] + }, + + // ENTONCES: prohíbe mármol en baño + action: { + type: 'forbid', + targetAttr: 'at:revest_bano', + values: ['op:revest_marmol'] + }, + + priority: 9, + bidirectional: false, + affects: ['at:calidad', 'at:revest_bano'], + severity: 'error', + + message: { + es: 'El mármol no está disponible en calidad estándar', + en: 'Marble is not available in standard quality' + } +} +``` + +#### 2. Regla ALLOW + +```typescript +{ + id: 'rl:roble_colores', + name: { + es: 'Roble colores permitidos', + en: 'Oak allowed colors' + }, + + // CUANDO: material es roble + condition: { + '==': [{ var: 'attributes.at_material' }, 'op:material_roble'] + }, + + // ENTONCES: solo permite blanco y natural + action: { + type: 'allow', + targetAttr: 'at:color', + values: ['op:color_blanco', 'op:color_natural'] + }, + + priority: 5, + bidirectional: false, + affects: ['at:material', 'at:color'], + severity: 'error', + + message: { + es: 'Roble solo admite blanco o natural', + en: 'Oak only allows white or natural' + } +} +``` + +#### 3. Regla REQUIRE + +```typescript +{ + id: 'rl:premium_require_material', + name: { + es: 'Premium requiere material', + en: 'Premium requires material' + }, + + // CUANDO: acabado es premium + condition: { + '==': [{ var: 'attributes.at_acabado' }, 'op:acabado_premium'] + }, + + // ENTONCES: material es obligatorio + action: { + type: 'require', + targetAttr: 'at:material', + values: [] // No importa qué valor, pero debe tener uno + }, + + priority: 8, + bidirectional: false, + affects: ['at:acabado', 'at:material'], + severity: 'error', + + message: { + es: 'El acabado premium requiere seleccionar material', + en: 'Premium finish requires material selection' + } +} +``` + +#### 4. Regla BIDIRECCIONAL + +```typescript +{ + id: 'rl:negro_premium', + name: { + es: 'Negro requiere premium', + en: 'Black requires premium' + }, + + // DIRECCIÓN 1: sanitario negro → prohíbe calidad estándar + condition: { + '==': [{ var: 'attributes.at_sanitario' }, 'op:sanit_negro'] + }, + action: { + type: 'forbid', + targetAttr: 'at:calidad', + values: ['op:calidad_estandar'] + }, + + // ⚠️ IMPORTANTE: Para bidireccional, crear 2 reglas separadas es más claro: + // - Regla 1: negro → prohíbe estándar + // - Regla 2: estándar → prohíbe negro + + priority: 8, + bidirectional: false, // ✅ Usar false y crear regla inversa manualmente + affects: ['at:sanitario', 'at:calidad'], + severity: 'error', + + message: { + es: 'Los sanitarios negros requieren calidad premium o superior', + en: 'Black sanitary ware requires premium quality or higher' + } +} +``` + +### Prioridades + +Las reglas se evalúan en orden de prioridad **descendente**: + +- **10-15**: Reglas críticas (incompatibilidades técnicas) +- **5-9**: Reglas importantes (restricciones de negocio) +- **1-4**: Reglas sugerencias (recomendaciones estéticas) + +--- + +## Ejemplo completo + +Ver archivo completo: [`housing-catalog.fixture.ts`](../tests/fixtures/housing-catalog.fixture.ts) + +### Estructura mínima + +```typescript +const CATALOGO_MINIMO: ConfigurationCatalog = { + name: { es: 'Mi Catálogo', en: 'My Catalog' }, + supportedLocales: ['es', 'en'], + defaultLocale: 'es', + + // 1. Definir opciones + options: { + 'op:color_rojo': { + id: 'op:color_rojo', + name: { es: 'Rojo', en: 'Red' }, + description: { es: 'Color rojo', en: 'Red color' }, + pricing: { baseAmount: 0 } + }, + 'op:color_azul': { + id: 'op:color_azul', + name: { es: 'Azul', en: 'Blue' }, + description: { es: 'Color azul', en: 'Blue color' }, + pricing: { baseAmount: 50 } + } + }, + + // 2. Definir objetos + objects: { + 'ob:producto': { + id: 'ob:producto', + name: { es: 'Producto', en: 'Product' }, + description: { es: 'Mi producto', en: 'My product' }, + + // Atributos globales + attributes: [ + { + id: 'at:color', + name: { es: 'Color', en: 'Color' }, + type: 'dynamic', + dataType: 'reference', + defaultValue: 'op:color_rojo', + options: [ + { optionId: 'op:color_rojo' }, + { optionId: 'op:color_azul' } + ], + display: { uiVisible: true, affectsPrice: true } + } + ], + + // Secciones + sections: { + 'sc:principal': { + id: 'sc:principal', + name: { es: 'Principal', en: 'Main' }, + description: { es: 'Sección principal', en: 'Main section' }, + attrs: [], + visualConfig: { + strategy: 'static_image', + imageUrlTemplate: '/images/{at_color}.jpg' + }, + availability: { mode: 'required' } + } + }, + + sectionOrder: ['sc:principal'] + } + }, + + // 3. Definir reglas (opcional) + rules: {} +}; +``` + +--- + +## 📚 Recursos adicionales + +- [Sistema de tipos](./TYPES.md) +- [Reglas de validación](./RULES.md) +- [i18n](./I18N.md) +- [Guía de creación](./CATALOG_GUIDE.md) diff --git a/readme/readme.md b/readme/readme.md new file mode 100644 index 0000000..ec81650 --- /dev/null +++ b/readme/readme.md @@ -0,0 +1,258 @@ +# CPQ Engine - Motor de Configuración de Producto + +Sistema TypeScript type-safe para configuración de productos complejos (Configure-Price-Quote) con soporte i18n, reglas de validación declarativas y renderizado visual condicional. + +## 🎯 Características principales + +- **Type-safe**: Sistema de tipos robusto con IDs con prefijos branded +- **i18n nativo**: Soporte multiidioma en todos los elementos del modelo +- **Reglas declarativas**: Motor de validación basado en JsonLogic +- **Visual rendering**: Generación dinámica de imágenes basada en selecciones +- **Arquitectura modular**: Separación clara entre modelo, motor y utilidades +- **Testing completo**: >180 tests unitarios con Vitest + +## 📦 Estructura del proyecto + +``` +src/ +├── types/ # Definiciones de tipos TypeScript +│ ├── core/ # Tipos base (Value, ID, JsonLogic) +│ ├── model/ # Tipos del modelo (Attribute, Section, Rule) +│ └── i18n/ # Sistema de internacionalización +├── engine/ # Motores de evaluación +│ ├── json-logic/ # Evaluador JsonLogic +│ └── rule-engine.ts # Motor de reglas de validación +├── utils/ # Utilidades +│ ├── ids/ # Sistema de IDs con prefijos +│ └── i18n/ # Funciones de traducción +├── messages/ # Sistema de mensajes centralizados +│ ├── errors.ts # Mensajes de error +│ ├── warnings.ts # Mensajes de advertencia +│ └── logger.ts # Logger estructurado +└── constants/ # Constantes globales + └── defaults.ts # Valores por defecto +``` + +## 🚀 Inicio rápido + +### Instalación + +```bash +npm install +``` + +### Ejecutar tests + +```bash +# Todos los tests +npm test + +# Con coverage +npm test -- --coverage + +# Watch mode +npm test -- --watch +``` + +### Ejemplo básico + +```typescript +import { CATALOGO_VIVIENDAS } from './fixtures/housing-catalog'; +import { RuleEngine } from './engine/rule-engine'; + +// Obtener un objeto configurable +const apartamento = CATALOGO_VIVIENDAS.objects['ob:apartamento']; + +// Crear estado de selección +const seleccion = { + 'at:calidad': 'op:calidad_premium', + 'at:suelo': 'op:suelo_parquet', + 'at:sanitario': 'op:sanit_negro' +}; + +// Validar con reglas +const engine = new RuleEngine(); +const reglas = Object.values(CATALOGO_VIVIENDAS.rules); + +const esValido = engine.isValid(reglas, seleccion); +const violaciones = engine.getViolations(reglas, seleccion); + +console.log('¿Configuración válida?', esValido); +console.log('Violaciones:', violaciones); +``` + +## 📚 Documentación + +- [**Modelo de datos**](./docs/MODEL.md) - Estructura completa del modelo +- [**Sistema de tipos**](./docs/TYPES.md) - Tipos TypeScript y branded IDs +- [**Reglas de validación**](./docs/RULES.md) - Cómo crear y usar reglas +- [**i18n**](./docs/I18N.md) - Sistema de internacionalización +- [**Motor de renderizado**](./docs/RENDERING.md) - Generación de imágenes +- [**Guía de creación**](./docs/CATALOG_GUIDE.md) - Cómo crear un catálogo completo + +## 🏗️ Arquitectura + +### Flujo de datos + +``` +┌─────────────────┐ +│ Catalog JSON │ +│ (Definición) │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ ┌──────────────┐ +│ Type System │────▶│ Validation │ +│ (Compile-time) │ │ (Runtime) │ +└────────┬────────┘ └──────┬───────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌──────────────┐ +│ User Selection │────▶│ Rule Engine │ +│ (State) │ │ (JsonLogic) │ +└────────┬────────┘ └──────┬───────┘ + │ │ + ▼ ▼ +┌─────────────────┐ ┌──────────────┐ +│ Visual Render │ │ Pricing │ +│ (Images) │ │ (Calculate) │ +└─────────────────┘ └──────────────┘ +``` + +### Capas + +1. **Types Layer** - Definiciones de tipos, contratos +2. **Model Layer** - Estructura del catálogo (Objects, Sections, Attributes) +3. **Engine Layer** - Lógica de negocio (Rules, JsonLogic, Rendering) +4. **Utils Layer** - Funciones auxiliares (IDs, i18n, logging) + +## 🧪 Testing + +El proyecto tiene >180 tests organizados en: + +- **Unit tests** - Utilidades individuales (IDs, i18n, JsonLogic) +- **Integration tests** - Motor de reglas completo +- **Fixture tests** - Catálogo de viviendas real + +```bash +# Ver coverage +npm test -- --coverage + +# Coverage actual: ~95% +``` + +## 🌍 i18n + +Sistema de internacionalización con 9 idiomas soportados: + +```typescript +const mensaje: I18nString = { + es: 'Hola mundo', + en: 'Hello world', + de: 'Hallo Welt', + fr: 'Bonjour le monde', + it: 'Ciao mondo', + pt: 'Olá mundo', + ca: 'Hola món', + eu: 'Kaixo mundua', + gl: 'Ola mundo' +}; + +// Uso +const texto = translate(mensaje, 'es'); // → "Hola mundo" +``` + +**Strings simples** también son válidos: + +```typescript +const simple: I18nString = 'OK'; // Válido en todos los idiomas +``` + +## 🎨 Ejemplo de modelo real + +El proyecto incluye un **catálogo completo de viviendas** como fixture: + +- 2 modelos: Apartamento (4 secciones) y Dúplex (5 secciones) +- 24 opciones de acabados (suelos, paredes, sanitarios, etc.) +- 6 reglas de validación con prioridades +- Generación de imágenes con templates +- Pricing dinámico + +Ver: [`housing-catalog.fixture.ts`](./tests/fixtures/housing-catalog.fixture.ts) + +## 🔧 Tecnologías + +- **TypeScript 5.x** - Lenguaje principal +- **Vitest** - Framework de testing +- **JsonLogic** - Motor de reglas declarativas +- **Branded Types** - IDs type-safe + +## 📝 Convenciones + +### IDs con prefijos + +Todos los IDs usan prefijos branded: + +| Tipo | Prefijo | Ejemplo | +|------|---------|---------| +| Attribute | `at:` | `at:calidad` | +| Section | `sc:` | `sc:salon` | +| Option | `op:` | `op:premium` | +| Object | `ob:` | `ob:apartamento` | +| Rule | `rl:` | `rl:negro_premium` | +| View | `vw:` | `vw:front` | +| Hotspot | `hs:` | `hs:punto1` | + +### Mensajes + +Todos los mensajes de error/warning/info están **centralizados** en `src/messages/`: + +```typescript +import { ERRORS, logger } from '@/messages'; + +// ❌ NO hacer +throw new Error('Invalid ID format'); + +// ✅ Hacer +const errorMsg = ERRORS.INVALID_ID_FORMAT(id); +logger.error(MESSAGE_CATEGORIES.ID, errorMsg, { id }); +throw new Error(JSON.stringify(errorMsg)); +``` + +### Naming + +- **Types**: PascalCase (`AttributeDisplay`, `ValidationRule`) +- **Interfaces**: PascalCase con prefijo `I` opcional +- **Functions**: camelCase (`extractIdPart`, `isValid`) +- **Constants**: UPPER_SNAKE_CASE (`DEFAULTS`, `MESSAGE_CATEGORIES`) +- **Files**: kebab-case (`rule-engine.ts`, `id-parser.ts`) + +## 🤝 Contribuir + +1. Fork el proyecto +2. Crea una rama (`git checkout -b feature/amazing`) +3. Commit cambios (`git commit -m 'Add amazing feature'`) +4. Push a la rama (`git push origin feature/amazing`) +5. Abre un Pull Request + +### Requisitos para PR + +- ✅ Todos los tests pasan +- ✅ Coverage >90% +- ✅ Sin errores de TypeScript +- ✅ Mensajes centralizados (no inline) +- ✅ Documentación actualizada + +## 📄 Licencia + +MIT License - ver [LICENSE](LICENSE) + +## 🙏 Agradecimientos + +- [JsonLogic](http://jsonlogic.com/) - Motor de reglas declarativas +- [Vitest](https://vitest.dev/) - Framework de testing + +--- + +**Versión**: 1.0.0 +**Última actualización**: Febrero 2026 diff --git a/readme/views.md b/readme/views.md new file mode 100644 index 0000000..5bbee36 --- /dev/null +++ b/readme/views.md @@ -0,0 +1,338 @@ +# Sistema de Vistas - Integración Completa + +## 📐 Conceptos clave + +Cada **Section** tiene múltiples **Views** (vistas) que representan diferentes ángulos o perspectivas visuales de esa sección. + +``` +Section (ej: Salón) +├── View: Front (frontal) +├── View: Side (lateral) +└── View: Top (cenital) +``` + +## 🏗️ Estructura + +### Section (actualizada) + +```typescript +interface Section { + id: SectionID; + name: I18nString; + attrs: Attribute[]; + + // ✅ NUEVO: Vistas disponibles (SIEMPRE presente) + views: Record; + + // ✅ NUEVO: Vista por defecto (SIEMPRE presente) + defaultView: ViewID; + + availability: SectionAvailability; +} +``` + +### SectionView + +```typescript +interface SectionView { + id: ViewID; // 'vw:front', 'vw:side', 'vw:top' + name: I18nString; + description?: I18nString; + visualConfig: SectionVisualConfig; // Configuración ESPECÍFICA de esta vista + order?: number; + icon?: string; +} +``` + +## 📝 Ejemplo completo + +```typescript +import type { Section, SectionView } from '@/types'; + +const VIEW_FRONT : ViewID = 'vw:front'; +const VIEW_SIDE : ViewID = 'vw:side'; +const VIEW_TOP : ViewID = 'vw:top'; + +const seccionSalon: Section = { + id: 'sc:salon', + name: { es: 'Salón', en: 'Living room' }, + description: { es: 'Salón - comedor principal', en: 'Main living - dining room' }, + + attrs: [ + { + id: 'at:suelo', + name: { es: 'Suelo', en: 'Flooring' }, + type: 'dynamic', + dataType: 'reference', + defaultValue: 'op:suelo_ceramica', + options: [ + { optionId: 'op:suelo_ceramica' }, + { optionId: 'op:suelo_parquet' } + ], + display: { uiVisible: true, affectsVisual: true, affectsPrice: true } + }, + { + id: 'at:pared', + name: { es: 'Color paredes', en: 'Wall color' }, + type: 'dynamic', + dataType: 'reference', + defaultValue: 'op:pared_blanco', + options: [ + { optionId: 'op:pared_blanco' }, + { optionId: 'op:pared_gris' } + ], + display: { uiVisible: true, affectsVisual: true } + } + ], + + // ✅ VISTAS: Diferentes ángulos de la misma sección + views: { + [VIEW_FRONT]: { + id: VIEW_FRONT, + name: { es: 'Vista frontal', en: 'Front view' }, + description: { es: 'Vista de frente del salón', en: 'Front view of living room' }, + visualConfig: { + strategy: 'static_image', + // Template incluye {view} para diferenciar ángulos + imageUrlTemplate: '{basePath}/{section}/{view}/{at_suelo}/{at_pared}.jpg', + basePath: '/renders/vivienda', + globalAttrDependencies: ['at:calidad'], + sectionAttrDependencies: ['at:suelo', 'at:pared'] + }, + order: 1, + icon: 'camera-front' + }, + + [VIEW_SIDE]: { + id: VIEW_SIDE, + name: { es: 'Vista lateral', en: 'Side view' }, + description: { es: 'Vista lateral del salón', en: 'Side view of living room' }, + visualConfig: { + strategy: 'static_image', + imageUrlTemplate: '{basePath}/{section}/{view}/{at_suelo}.jpg', + basePath: '/renders/vivienda', + globalAttrDependencies: ['at:calidad'], + // Solo el suelo afecta esta vista + sectionAttrDependencies: ['at:suelo'] + }, + order: 2, + icon: 'camera-side' + }, + + [VIEW_TOP]: { + id: VIEW_TOP, + name: { es: 'Vista cenital', en: 'Top view' }, + description: { es: 'Vista desde arriba', en: 'Top-down view' }, + visualConfig: { + strategy: 'static_image', + imageUrlTemplate: '{basePath}/{section}/{view}/{at_suelo}/{at_muebles}.jpg', + basePath: '/renders/vivienda', + globalAttrDependencies: ['at:calidad'], + // Vista cenital muestra suelo y distribución de muebles + sectionAttrDependencies: ['at:suelo', 'at:muebles'] + }, + order: 3, + icon: 'camera-top' + } + }, + + // ✅ Vista por defecto (la primera que se muestra al usuario) + defaultView: VIEW_FRONT, + + availability: { mode: 'required' } +}; +``` + +## 🔄 Generación de URLs + +### Placeholders en `imageUrlTemplate` + +El template soporta estos placeholders: + +| Placeholder | Reemplazado por | Ejemplo | +|-------------|----------------|---------| +| `{basePath}` | basePath de la config | `/renders/vivienda` | +| `{section}` | ID de sección sin prefijo | `salon` (de `sc:salon`) | +| `{view}` | ID de vista sin prefijo | `front` (de `vw:front`) | +| `{at_}` | Valor del atributo normalizado | `parquet` (de `at:suelo` = `op:suelo_parquet`) | + +### Ejemplo de resolución + +**Estado:** +```typescript +const state = { + 'at:suelo': 'op:suelo_parquet', + 'at:pared': 'op:pared_gris', + 'at:calidad': 'op:calidad_premium' +}; +``` + +**Template:** +``` +{basePath}/{section}/{view}/{at_suelo}/{at_pared}.jpg +``` + +**URL resultante para vista frontal:** +``` +/renders/vivienda/salon/front/parquet/gris.jpg +``` + +**URL resultante para vista lateral:** +``` +/renders/vivienda/salon/side/parquet.jpg +``` + +## 🎨 Uso en el UI + +```typescript +// Obtener sección +const salon = apartamento.sections['sc:salon']; + +// Listar todas las vistas disponibles +const vistasFront = Object.values(salon.views) + .sort((a, b) => (a.order || 0) - (b.order || 0)); + +console.log(vistasFront); +// [ +// { id: 'vw:front', name: { es: 'Vista frontal' }, ... }, +// { id: 'vw:side', name: { es: 'Vista lateral' }, ... }, +// { id: 'vw:top', name: { es: 'Vista cenital' }, ... } +// ] + +// Vista activa (por defecto) +const vistaActual = salon.views[salon.defaultView]; + +// Cambiar vista +const nuevaVista = salon.views['vw:side']; + +// Generar URL de la vista actual +const urlImagen = generarImagenUrl( + salon.id, + vistaActual.id, + vistaActual.visualConfig, + state +); +``` + +## 🏭 Estrategias de renderizado por vista + +Cada vista puede tener su propia estrategia: + +```typescript +views: { + 'vw:front': { + id: 'vw:front', + name: { es: 'Frontal', en: 'Front' }, + visualConfig: { + strategy: 'static_image', // ← Imagen estática + imageUrlTemplate: '...' + } + }, + 'vw:360': { + id: 'vw:360', + name: { es: 'Vista 360º', en: '360º view' }, + visualConfig: { + strategy: 'three_d', // ← Modelo 3D interactivo + threeDConfig: { + modelUrl: '/models/salon.glb', + textureAttrs: [ + { attrId: 'at:suelo', scope: 'section' }, + { attrId: 'at:pared', scope: 'section' } + ] + } + } + }, + 'vw:ar': { + id: 'vw:ar', + name: { es: 'Realidad Aumentada', en: 'AR' }, + visualConfig: { + strategy: 'api_generated', // ← API externa + apiConfig: { + endpoint: 'https://ar.example.com/generate', + method: 'POST', + timeout: 10000 + } + } + } +} +``` + +## 📱 Vistas contextuales + +Las vistas pueden mostrarse condicionalmente: + +```typescript +{ + id: 'vw:detail_plumbing', + name: { es: 'Detalle fontanería', en: 'Plumbing detail' }, + visualConfig: { + strategy: 'static_image', + imageUrlTemplate: '{basePath}/{section}/detail/{at_sanitario}.jpg' + }, + // Nota: No hay campo 'visible' aquí porque las vistas + // siempre están disponibles. El UI decide si mostrarlas. + order: 10 // Ordenar al final +} +``` + +## 🔑 Claves de diseño + +### 1. Cada sección DEBE tener al menos una vista + +```typescript +// ❌ MAL +views: {} + +// ✅ BIEN +views: { + 'vw:default': { + id: 'vw:default', + name: { es: 'Vista principal', en: 'Main view' }, + visualConfig: { /* ... */ } + } +} +``` + +### 2. defaultView DEBE existir en views + +```typescript +// ❌ MAL +views: { 'vw:front': { /* ... */ } }, +defaultView: 'vw:side' // ← No existe! + +// ✅ BIEN +views: { + 'vw:front': { /* ... */ }, + 'vw:side': { /* ... */ } +}, +defaultView: 'vw:front' // ← Existe +``` + +### 3. Los atributos que afectan CUALQUIER vista deben estar en dependencies + +```typescript +// Vista frontal usa: suelo, pared +// Vista lateral usa: suelo +// Vista cenital usa: suelo, muebles + +// En la sección, marca affectsVisual: true para todos +attrs: [ + { id: 'at:suelo', display: { affectsVisual: true } }, // ✅ Afecta 3 vistas + { id: 'at:pared', display: { affectsVisual: true } }, // ✅ Afecta 1 vista + { id: 'at:muebles', display: { affectsVisual: true } } // ✅ Afecta 1 vista +] +``` + +## 🎯 Ventajas del sistema de vistas + +✅ **Flexibilidad**: Cada vista puede tener diferente estrategia de renderizado +✅ **Optimización**: Solo cargar assets de la vista activa +✅ **Claridad**: Separación explícita de ángulos/perspectivas +✅ **Escalabilidad**: Añadir nuevas vistas sin modificar la estructura +✅ **UX**: Usuario puede cambiar de vista según necesidad + +## 📚 Recursos + +- [Modelo completo](./MODEL.md) +- [Visual Rendering](./RENDERING.md) +- [URL Builder](./URL_BUILDER.md) \ No newline at end of file diff --git a/src/adapters/svelte/components/Configurator.svelte b/src/adapters/svelte/components/Configurator.svelte new file mode 100644 index 0000000..e94c651 --- /dev/null +++ b/src/adapters/svelte/components/Configurator.svelte @@ -0,0 +1,346 @@ + + + + +
+ +
+

{$config.object.name.es}

+

{$config.object.description.es}

+ +
+ + + +
+
+ +
+ + + + +
+ {#if currentSection} +

{currentSection.name.es}

+

{currentSection.description.es}

+ + + {#each currentSection.attrs as attr} + {#if attr.type === 'dynamic' && attr.display?.uiVisible} +
+ + + + + + {#each config.getViolationsForAttribute(attr.id) as violation} +
+ {violation.rule.message.es} +
+ {/each} +
+ {/if} + {/each} + {/if} +
+ + +
+ {#if currentView} +
+

Vistas

+ {#each Object.values(currentSection.views || {}) as view} + + {/each} +
+ +
+

{currentView.name.es}

+ +
+

Vista: {currentView.id}

+

Template: {currentView.visualConfig.imageUrlTemplate}

+
+
+ {:else} +
+

Esta sección no tiene visualización

+
+ {/if} +
+
+ + + {#if validation} +
+ {#if validation.hasErrors} +
+ ❌ {validation.violations.filter(v => v.severity === 'error').length} errores +
+ {/if} + {#if validation.hasWarnings} +
+ ⚠️ {validation.violations.filter(v => v.severity === 'warning').length} advertencias +
+ {/if} + {#if validation.isValid} +
+ ✅ Configuración válida +
+ {/if} +
+ {/if} +
+ + diff --git a/src/adapters/svelte/stores/configuration.ts b/src/adapters/svelte/stores/configuration.ts new file mode 100644 index 0000000..5bb0dfe --- /dev/null +++ b/src/adapters/svelte/stores/configuration.ts @@ -0,0 +1,197 @@ +/** + * ============================================================================ + * SVELTE CONFIGURATION STORE + * ============================================================================ + * + * Adapter que expone ConfigurationState como Svelte store. + */ + +import { writable, derived, type Readable } from 'svelte/store'; +import type { + ConfigurableObject, + ConfigurationCatalog, + AttrID, + OptionID, + SectionID, + ViewID, + Section, + SectionView +} from '@/types'; +import { + ConfigurationState, + type ConfigurationStateOptions +} from '@/state/ConfigurationState'; +import type { ValidationResult } from '@/state/ValidationState'; +import type { SelectionStateData } from '@/state/SelectionState'; + +// ============================================================================ +// STORE STATE INTERFACE +// ============================================================================ + +export interface ConfigurationStoreState { + catalog : ConfigurationCatalog; + object : ConfigurableObject; + selection : SelectionStateData; + validation : ValidationResult | null; + currentSection : Section | null; + currentSectionId: SectionID | null; + currentView : SectionView | null; + currentViewId : ViewID | null; + isValid : boolean; +} + +// ============================================================================ +// CONFIGURATION STORE +// ============================================================================ + +/** + * Crea un store de configuración reactivo para Svelte + */ +export function createConfigurationStore(options: ConfigurationStateOptions) { + // Estado core (framework agnostic) + const state = new ConfigurationState(options); + + // Store interno con versión para forzar actualizaciones + const { subscribe, set, update } = writable(getStoreState()); + + // Función helper para obtener el estado actual del store + function getStoreState(): ConfigurationStoreState { + return { + catalog : state.getCatalog(), + object : state.getObject(), + selection : state.getAllSelections(), + validation : state.getValidationResult(), + currentSection : state.getCurrentSection(), + currentSectionId: state.getCurrentSectionId(), + currentView : state.getCurrentView(), + currentViewId : state.getCurrentViewId(), + isValid : state.isValid() + }; + } + + // Suscribirse a cambios del estado core + state.subscribe(() => { + set(getStoreState()); + }); + + // ======================================================================== + // PUBLIC API + // ======================================================================== + + return { + subscribe, + + // Selection actions + selectOption: (attrId: AttrID, optionId: OptionID) => { + state.selectOption(attrId, optionId); + }, + + clearSelection: (attrId: AttrID) => { + state.clearSelection(attrId); + }, + + // Navigation actions + setCurrentSection: (sectionId: SectionID) => { + state.setCurrentSection(sectionId); + }, + + setCurrentView: (viewId: ViewID) => { + state.setCurrentView(viewId); + }, + + // Validation actions + validate: () => { + return state.validate(); + }, + + // Helpers + getSelection: (attrId: AttrID) => { + return state.getSelection(attrId); + }, + + getAllowedValues: (attrId: AttrID) => { + return state.getAllowedValues(attrId); + }, + + isRequired: (attrId: AttrID) => { + return state.isRequired(attrId); + }, + + hasErrorsForAttribute: (attrId: AttrID) => { + return state.hasErrorsForAttribute(attrId); + }, + + getViolationsForAttribute: (attrId: AttrID) => { + return state.getViolationsForAttribute(attrId); + }, + + // Persistence + toJSON: () => state.toJSON(), + + fromJSON: (data: ReturnType) => { + state.fromJSON(data); + }, + + // Utilities + reset: () => state.reset(), + undo: () => state.undo(), + + // Direct access to state (for advanced use) + _state: state + }; +} + +// ============================================================================ +// DERIVED STORES (COMPUTED VALUES) +// ============================================================================ + +/** + * Derived store para la sección actual + */ +export function deriveCurrentSection( + configStore: ReturnType +): Readable
{ + return derived(configStore, $config => $config.currentSection); +} + +/** + * Derived store para la vista actual + */ +export function deriveCurrentView( + configStore: ReturnType +): Readable { + return derived(configStore, $config => $config.currentView); +} + +/** + * Derived store para validación + */ +export function deriveValidation( + configStore: ReturnType +): Readable { + return derived(configStore, $config => $config.validation); +} + +/** + * Derived store para verificar si es válido + */ +export function deriveIsValid( + configStore: ReturnType +): Readable { + return derived(configStore, $config => $config.isValid); +} + +/** + * Derived store para selección + */ +export function deriveSelection( + configStore: ReturnType +): Readable { + return derived(configStore, $config => $config.selection); +} + +// ============================================================================ +// TYPE EXPORTS +// ============================================================================ + +export type ConfigurationStore = ReturnType; diff --git a/src/messages/validation-section.ts b/src/messages/validation-section.ts new file mode 100644 index 0000000..79f6ad0 --- /dev/null +++ b/src/messages/validation-section.ts @@ -0,0 +1,90 @@ +/** + * ============================================================================ + * VALIDATION MESSAGES - Section & Object + * ============================================================================ + */ + +import type { I18nString } from '@/types'; + +export const VALIDATION_SECTION = { + // Section - Views coherence + HAS_VIEWS_NO_DEFAULT: (): I18nString => ({ + es: 'La sección tiene vistas pero no tiene defaultView', + en: 'Section has views but no defaultView' + }), + + HAS_DEFAULT_NO_VIEWS: (): I18nString => ({ + es: 'La sección tiene defaultView pero no tiene vistas', + en: 'Section has defaultView but no views' + }), + + DEFAULT_VIEW_NOT_FOUND: (viewId: string): I18nString => ({ + es: `La vista por defecto '${viewId}' no existe en las vistas disponibles`, + en: `Default view '${viewId}' not found in available views` + }), + + // Section - Visual coherence + VISUAL_ATTRS_NO_VIEWS: (): I18nString => ({ + es: 'La sección tiene atributos con affectsVisual=true pero no tiene vistas. Los cambios visuales no tendrán efecto.', + en: 'Section has attributes with affectsVisual=true but no views. Visual changes will have no effect.' + }), + + // Section - Attributes + DUPLICATE_ATTRIBUTE_ID: (attrId: string): I18nString => ({ + es: `ID de atributo duplicado: ${attrId}`, + en: `Duplicate attribute ID: ${attrId}` + }), + + // View - Dependencies + DEPENDENCY_NOT_IN_SECTION: (attrId: string): I18nString => ({ + es: `La dependencia '${attrId}' no existe en los atributos de la sección`, + en: `Dependency '${attrId}' not found in section attributes` + }), + + // View - Template validation + TEMPLATE_ATTR_NOT_IN_SECTION: (attrId: string): I18nString => ({ + es: `El template referencia el atributo '${attrId}' que no existe en la sección`, + en: `Template references attribute '${attrId}' not found in section` + }), + + TEMPLATE_ATTR_NOT_IN_DEPS: (attrId: string): I18nString => ({ + es: `El template usa '${attrId}' pero no está en sectionAttrDependencies`, + en: `Template uses '${attrId}' but it's not in sectionAttrDependencies` + }), + + UNKNOWN_PLACEHOLDER: (placeholder: string): I18nString => ({ + es: `Placeholder desconocido '${placeholder}' en el template`, + en: `Unknown placeholder '${placeholder}' in template` + }) +} as const; + +export const VALIDATION_OBJECT = { + // Object - Visual Strategy + MISSING_VISUAL_STRATEGY: (): I18nString => ({ + es: 'El objeto no tiene visualStrategy definida', + en: 'Object missing visualStrategy' + }), + + VISUAL_SECTIONS_WITH_NONE_STRATEGY: (): I18nString => ({ + es: 'El objeto tiene secciones con vistas pero visualStrategy es "none"', + en: 'Object has visual sections but visualStrategy is "none"' + }), + + // Object - BasePath + MISSING_BASE_PATH: (strategy: string): I18nString => ({ + es: `La estrategia '${strategy}' requiere basePath a nivel de objeto o vista`, + en: `visualStrategy '${strategy}' requires basePath at object or view level` + }), + + // Object - API Config + MISSING_API_CONFIG: (): I18nString => ({ + es: 'La estrategia "api_generated" requiere apiConfig a nivel de objeto o vista', + en: 'visualStrategy "api_generated" requires apiConfig at object or view level' + }), + + // Object - Section Order + SECTION_ORDER_UNKNOWN_SECTION: (sectionId: string): I18nString => ({ + es: `sectionOrder referencia una sección desconocida: '${sectionId}'`, + en: `sectionOrder references unknown section '${sectionId}'` + }) +} as const; \ No newline at end of file diff --git a/src/state/ConfigurationState.ts b/src/state/ConfigurationState.ts new file mode 100644 index 0000000..f7c10d0 --- /dev/null +++ b/src/state/ConfigurationState.ts @@ -0,0 +1,377 @@ +/** + * ============================================================================ + * CONFIGURATION STATE - Framework Agnostic + * ============================================================================ + * + * Orquesta todo el estado de la configuración. + * Coordina SelectionState, ValidationState, y el modelo del catálogo. + */ + +import type { + ConfigurableObject, + ConfigurationCatalog, + AttrID, + OptionID, + SectionID, + ViewID, ObjectID +} from '@/types'; +import { SelectionState, type SelectionStateChangeEvent } from './SelectionState'; +import { ValidationState, type ValidationResult } from './ValidationState'; + +export interface ConfigurationStateOptions { + catalog: ConfigurationCatalog; + objectId: string; + autoValidate?: boolean; // Auto-validar en cada cambio (default: true) +} + +/** + * Estado completo de la configuración + */ +export class ConfigurationState { + // Core state + private catalog: ConfigurationCatalog; + private object: ConfigurableObject; + private selection: SelectionState; + private validation: ValidationState; + + // Options + private autoValidate: boolean; + + // Current UI state + private currentSectionId: SectionID | null = null; + private currentViewId: ViewID | null = null; + + // Listeners + private stateChangeListeners: Set<() => void> = new Set(); + + constructor(options: ConfigurationStateOptions) { + this.catalog = options.catalog; + this.autoValidate = options.autoValidate ?? true; + + // Buscar el objeto + const obj = this.catalog.objects[options.objectId as ObjectID]; + if (!obj) { + throw new Error(`Object ${options.objectId} not found in catalog`); + } + this.object = obj; + + // Inicializar estados + this.selection = new SelectionState(); + this.validation = new ValidationState(); + + // Configurar reglas de validación + if (this.catalog.rules) { + this.validation.setRules(Object.values(this.catalog.rules)); + } + + // Suscribirse a cambios de selección para auto-validar + this.selection.subscribe((event) => { + if (this.autoValidate) { + this.validation.validate(this.selection.getAll()); + } + this.notifyStateChange(); + }); + + // Suscribirse a cambios de validación + this.validation.subscribe(() => { + this.notifyStateChange(); + }); + + // Inicializar con valores por defecto + this.initializeDefaults(); + } + + // ======================================================================== + // INITIALIZATION + // ======================================================================== + + /** + * Inicializa con valores por defecto del objeto + */ + private initializeDefaults(): void { + // Atributos globales + this.object.attributes.forEach(attr => { + if (attr.type === 'dynamic' && attr.defaultValue) { + this.selection.set(attr.id, attr.defaultValue); + } + }); + + // Atributos de secciones + Object.values(this.object.sections).forEach(section => { + section.attrs.forEach(attr => { + if (attr.type === 'dynamic' && attr.defaultValue) { + this.selection.set(attr.id, attr.defaultValue); + } + }); + }); + + // Establecer primera sección como actual + const firstSectionId = this.object.sectionOrder?.[0] || + Object.keys(this.object.sections)[0] as SectionID; + + if (firstSectionId) { + this.setCurrentSection(firstSectionId); + } + } + + // ======================================================================== + // CATALOG & OBJECT + // ======================================================================== + + /** + * Obtiene el catálogo completo + */ + getCatalog(): ConfigurationCatalog { + return this.catalog; + } + + /** + * Obtiene el objeto actual + */ + getObject(): ConfigurableObject { + return this.object; + } + + // ======================================================================== + // SELECTION + // ======================================================================== + + /** + * Selecciona una opción para un atributo + */ + selectOption(attrId: AttrID, optionId: OptionID): void { + // Verificar si el valor está permitido + if (!this.validation.isValueAllowed(attrId, optionId, this.selection.getAll())) { + console.warn(`Option ${optionId} is not allowed for attribute ${attrId}`); + return; + } + + this.selection.set(attrId, optionId); + } + + /** + * Limpia la selección de un atributo + */ + clearSelection(attrId: AttrID): void { + this.selection.clear(attrId); + } + + /** + * Obtiene el valor seleccionado de un atributo + */ + getSelection(attrId: AttrID): OptionID | undefined { + return this.selection.get(attrId); + } + + /** + * Obtiene toda la selección + */ + getAllSelections(): Record { + return this.selection.getAll(); + } + + // ======================================================================== + // VALIDATION + // ======================================================================== + + /** + * Valida el estado actual + */ + validate(): ValidationResult { + return this.validation.validate(this.selection.getAll()); + } + + /** + * Obtiene el resultado de la última validación + */ + getValidationResult(): ValidationResult | null { + return this.validation.getLastResult(); + } + + /** + * Verifica si la configuración es válida + */ + isValid(): boolean { + const result = this.validation.getLastResult(); + return result?.isValid ?? true; + } + + /** + * Obtiene violaciones para un atributo + */ + getViolationsForAttribute(attrId: AttrID) { + return this.validation.getViolationsForAttribute(attrId); + } + + /** + * Verifica si un atributo tiene errores + */ + hasErrorsForAttribute(attrId: AttrID): boolean { + return this.validation.hasErrorsForAttribute(attrId); + } + + /** + * Obtiene valores permitidos para un atributo + */ + getAllowedValues(attrId: AttrID): OptionID[] | undefined { + return this.validation.getAllowedValues(attrId, this.selection.getAll()); + } + + /** + * Verifica si un atributo es requerido + */ + isRequired(attrId: AttrID): boolean { + return this.validation.isRequired(attrId, this.selection.getAll()); + } + + // ======================================================================== + // NAVIGATION + // ======================================================================== + + /** + * Establece la sección actual + */ + setCurrentSection(sectionId: SectionID): void { + const section = this.object.sections[sectionId]; + if (!section) { + console.warn(`Section ${sectionId} not found`); + return; + } + + this.currentSectionId = sectionId; + + // Establecer vista por defecto si tiene vistas + if (section.views && section.defaultView) { + this.currentViewId = section.defaultView; + } else { + this.currentViewId = null; + } + + this.notifyStateChange(); + } + + /** + * Obtiene la sección actual + */ + getCurrentSection() { + return this.currentSectionId + ? this.object.sections[this.currentSectionId] + : null; + } + + /** + * Establece la vista actual + */ + setCurrentView(viewId: ViewID): void { + const section = this.getCurrentSection(); + if (!section?.views?.[viewId]) { + console.warn(`View ${viewId} not found in current section`); + return; + } + + this.currentViewId = viewId; + this.notifyStateChange(); + } + + /** + * Obtiene la vista actual + */ + getCurrentView() { + const section = this.getCurrentSection(); + return this.currentViewId && section?.views + ? section.views[this.currentViewId] + : null; + } + + /** + * Obtiene el ID de la sección actual + */ + getCurrentSectionId(): SectionID | null { + return this.currentSectionId; + } + + /** + * Obtiene el ID de la vista actual + */ + getCurrentViewId(): ViewID | null { + return this.currentViewId; + } + + // ======================================================================== + // SUBSCRIPTION + // ======================================================================== + + /** + * Suscribe a cambios de estado + */ + subscribe(listener: () => void): () => void { + this.stateChangeListeners.add(listener); + + return () => { + this.stateChangeListeners.delete(listener); + }; + } + + /** + * Notifica cambios de estado + */ + private notifyStateChange(): void { + this.stateChangeListeners.forEach(listener => listener()); + } + + // ======================================================================== + // PERSISTENCE + // ======================================================================== + + /** + * Serializa el estado a JSON + */ + toJSON() { + return { + objectId: this.object.id, + selection: this.selection.toJSON(), + currentSectionId: this.currentSectionId, + currentViewId: this.currentViewId + }; + } + + /** + * Restaura el estado desde JSON + */ + fromJSON(data: ReturnType): void { + this.selection.fromJSON(data.selection); + + if (data.currentSectionId) { + this.setCurrentSection(data.currentSectionId); + } + + if (data.currentViewId) { + this.setCurrentView(data.currentViewId); + } + + // Re-validar + if (this.autoValidate) { + this.validation.validate(this.selection.getAll()); + } + } + + // ======================================================================== + // UTILITIES + // ======================================================================== + + /** + * Resetea toda la configuración + */ + reset(): void { + this.selection.clearAll(); + this.validation.clear(); + this.initializeDefaults(); + } + + /** + * Deshace el último cambio + */ + undo(): boolean { + return this.selection.undo(); + } +} diff --git a/src/state/SelectionState.ts b/src/state/SelectionState.ts new file mode 100644 index 0000000..a8a4a1b --- /dev/null +++ b/src/state/SelectionState.ts @@ -0,0 +1,206 @@ +/** + * ============================================================================ + * SELECTION STATE - Framework Agnostic + * ============================================================================ + * + * Gestiona el estado de selección de opciones. + * No depende de ningún framework, puede usarse con Svelte, React, Vue, etc. + */ + +import type { AttrID, OptionID, SectionID } from '@/types'; + +export type SelectionStateData = Record; + +export interface SelectionStateChangeEvent { + attrId: AttrID; + previousValue?: OptionID; + newValue?: OptionID; + timestamp: number; +} + +/** + * Estado de selección de opciones + */ +export class SelectionState { + private state: SelectionStateData = {}; + private listeners: Set<(event: SelectionStateChangeEvent) => void> = new Set(); + private history: SelectionStateChangeEvent[] = []; + + /** + * Establece el valor de un atributo + */ + set(attrId: AttrID, value: OptionID | undefined): void { + const previousValue = this.state[attrId]; + + // Si el valor no cambia, no hacer nada + if (previousValue === value) return; + + const event: SelectionStateChangeEvent = { + attrId, + previousValue, + newValue: value, + timestamp: Date.now() + }; + + if (value === undefined) { + delete this.state[attrId]; + } else { + this.state[attrId] = value; + } + + // Registrar en historial + this.history.push(event); + + // Notificar listeners + this.notifyListeners(event); + } + + /** + * Obtiene el valor de un atributo + */ + get(attrId: AttrID): OptionID | undefined { + return this.state[attrId]; + } + + /** + * Obtiene todo el estado + */ + getAll(): SelectionStateData { + return { ...this.state }; + } + + /** + * Verifica si un atributo tiene valor + */ + has(attrId: AttrID): boolean { + return attrId in this.state; + } + + /** + * Limpia el valor de un atributo + */ + clear(attrId: AttrID): void { + this.set(attrId, undefined); + } + + /** + * Limpia todo el estado + */ + clearAll(): void { + const attrIds = Object.keys(this.state) as AttrID[]; + attrIds.forEach(attrId => this.clear(attrId)); + } + + /** + * Establece múltiples valores a la vez + */ + setMany(values: Partial): void { + Object.entries(values).forEach(([attrId, value]) => { + if (value !== undefined) { + this.set(attrId as AttrID, value); + } + }); + } + + /** + * Obtiene valores de una sección específica + */ + getBySection(sectionId: SectionID, attrIds: AttrID[]): Partial { + const result: Partial = {}; + attrIds.forEach(attrId => { + const value = this.state[attrId]; + if (value !== undefined) { + result[attrId] = value; + } + }); + return result; + } + + /** + * Suscribe un listener a cambios + */ + subscribe(listener: (event: SelectionStateChangeEvent) => void): () => void { + this.listeners.add(listener); + + // Retorna función para desuscribirse + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Notifica a todos los listeners + */ + private notifyListeners(event: SelectionStateChangeEvent): void { + this.listeners.forEach(listener => listener(event)); + } + + /** + * Obtiene el historial de cambios + */ + getHistory(): SelectionStateChangeEvent[] { + return [...this.history]; + } + + /** + * Limpia el historial + */ + clearHistory(): void { + this.history = []; + } + + /** + * Deshace el último cambio + */ + undo(): boolean { + if (this.history.length === 0) return false; + + const lastEvent = this.history.pop()!; + + // Revertir sin añadir al historial ni notificar + if (lastEvent.previousValue === undefined) { + delete this.state[lastEvent.attrId]; + } else { + this.state[lastEvent.attrId] = lastEvent.previousValue; + } + + // Notificar del cambio + this.notifyListeners({ + attrId: lastEvent.attrId, + previousValue: lastEvent.newValue, + newValue: lastEvent.previousValue, + timestamp: Date.now() + }); + + return true; + } + + /** + * Obtiene el tamaño del estado + */ + get size(): number { + return Object.keys(this.state).length; + } + + /** + * Verifica si el estado está vacío + */ + get isEmpty(): boolean { + return this.size === 0; + } + + /** + * Serializa el estado a JSON + */ + toJSON(): SelectionStateData { + return this.getAll(); + } + + /** + * Restaura el estado desde JSON + */ + fromJSON(data: SelectionStateData): void { + this.clearAll(); + this.setMany(data); + } +} diff --git a/src/state/ValidationState.ts b/src/state/ValidationState.ts new file mode 100644 index 0000000..dfb9bc6 --- /dev/null +++ b/src/state/ValidationState.ts @@ -0,0 +1,176 @@ +/** + * ============================================================================ + * VALIDATION STATE - Framework Agnostic + * ============================================================================ + * + * Gestiona el estado de validación y violaciones. + */ + +import type { AttrID, ValidationRule, RuleID } from '@/types'; +import type { SelectionStateData } from './SelectionState'; +import { RuleEngine } from '@/engine/rule-engine'; + +export interface Violation { + rule: ValidationRule; + severity: 'error' | 'warning' | 'info'; + attrId?: AttrID; +} + +export interface ValidationResult { + isValid: boolean; + hasErrors: boolean; + hasWarnings: boolean; + violations: Violation[]; + violationsByAttribute: Map; +} + +/** + * Estado de validación + */ +export class ValidationState { + private engine: RuleEngine; + private rules: ValidationRule[] = []; + private lastResult: ValidationResult | null = null; + private listeners: Set<(result: ValidationResult) => void> = new Set(); + + constructor() { + this.engine = new RuleEngine(); + } + + /** + * Establece las reglas de validación + */ + setRules(rules: ValidationRule[]): void { + this.rules = rules; + } + + /** + * Valida el estado actual + */ + validate(selectionState: SelectionStateData): ValidationResult { + const violations = this.engine.getViolations(this.rules, selectionState); + + const hasErrors = violations.some(v => v.severity === 'error'); + const hasWarnings = violations.some(v => v.severity === 'warning'); + + // Agrupar violaciones por atributo + const violationsByAttribute = new Map(); + + violations.forEach(violation => { + if (violation.rule.action?.targetAttr) { + const attrId = violation.rule.action.targetAttr; + if (!violationsByAttribute.has(attrId)) { + violationsByAttribute.set(attrId, []); + } + violationsByAttribute.get(attrId)!.push(violation); + } + }); + + const result: ValidationResult = { + isValid: !hasErrors, + hasErrors, + hasWarnings, + violations, + violationsByAttribute + }; + + this.lastResult = result; + this.notifyListeners(result); + + return result; + } + + /** + * Obtiene el último resultado de validación + */ + getLastResult(): ValidationResult | null { + return this.lastResult; + } + + /** + * Obtiene violaciones de un atributo específico + */ + getViolationsForAttribute(attrId: AttrID): Violation[] { + if (!this.lastResult) return []; + return this.lastResult.violationsByAttribute.get(attrId) || []; + } + + /** + * Verifica si un atributo tiene errores + */ + hasErrorsForAttribute(attrId: AttrID): boolean { + const violations = this.getViolationsForAttribute(attrId); + return violations.some(v => v.severity === 'error'); + } + + /** + * Verifica si un valor es permitido para un atributo + */ + isValueAllowed(attrId: AttrID, value: OptionID, selectionState: SelectionStateData): boolean { + return this.engine.isValueAllowed(attrId, value, this.rules, selectionState); + } + + /** + * Obtiene valores permitidos para un atributo + */ + getAllowedValues(attrId: AttrID, selectionState: SelectionStateData): OptionID[] | undefined { + const results = this.engine.getAttributeResults(this.rules, selectionState); + const attrResult = results.get(attrId); + + if (!attrResult) return undefined; + + return attrResult.allowedValues; + } + + /** + * Obtiene valores prohibidos para un atributo + */ + getForbiddenValues(attrId: AttrID, selectionState: SelectionStateData): OptionID[] { + const results = this.engine.getAttributeResults(this.rules, selectionState); + const attrResult = results.get(attrId); + + if (!attrResult) return []; + + return attrResult.forbiddenValues || []; + } + + /** + * Verifica si un atributo es requerido + */ + isRequired(attrId: AttrID, selectionState: SelectionStateData): boolean { + const results = this.engine.getAttributeResults(this.rules, selectionState); + const attrResult = results.get(attrId); + + return attrResult?.required || false; + } + + /** + * Suscribe un listener a cambios de validación + */ + subscribe(listener: (result: ValidationResult) => void): () => void { + this.listeners.add(listener); + + // Si ya hay un resultado, notificar inmediatamente + if (this.lastResult) { + listener(this.lastResult); + } + + return () => { + this.listeners.delete(listener); + }; + } + + /** + * Notifica a todos los listeners + */ + private notifyListeners(result: ValidationResult): void { + this.listeners.forEach(listener => listener(result)); + } + + /** + * Limpia el estado de validación + */ + clear(): void { + this.lastResult = null; + } +} diff --git a/src/types/model/object.types.ts b/src/types/model/object.types.ts index bd341b4..21405a9 100644 --- a/src/types/model/object.types.ts +++ b/src/types/model/object.types.ts @@ -1,22 +1,131 @@ /** * ============================================================================ - * CONFIGURABLE OBJECT TYPES + * CONFIGURABLE OBJECT TYPES - REFACTORED * ============================================================================ + * + * ConfigurableObject ahora define la estrategia de renderizado global. */ import type { I18nString } from '../i18n'; -import type { Metadata } from '../core'; -import type { ObjectID, SectionID, Attribute, Section } from '@/types'; +import type { Metadata, APIConfig } from '../core'; +import type { ObjectID, SectionID, Attribute, Section } from '@/types'; +// ============================================================================ +// RENDERING STRATEGY +// ============================================================================ +/** + * Estrategia de renderizado para TODO el objeto configurable. + * Todas las vistas de todas las secciones usan esta estrategia. + */ +export type RenderingStrategy = + | 'static_image' // URLs estáticas con placeholders + | 'dynamic_image' // URLs dinámicas calculadas en runtime + | 'api_generated' // Generación async via API externa + | 'three_d' // Modelo 3D con texturas dinámicas + | 'composite_layers' // Composición de capas PNG + | 'none'; // Sin renderizado visual + +// ============================================================================ +// GLOBAL VISUAL CONFIG +// ============================================================================ + +/** + * Configuración visual global del objeto. + * Actúa como FALLBACK para todas las vistas que no especifiquen sus propios valores. + */ +export interface GlobalVisualConfig { + /** + * Ruta base para todas las imágenes del objeto. + * Las vistas pueden override este valor. + * + * Ejemplo: '/renders/vivienda' + */ + basePath?: string; + + /** + * Configuración API global. + * Solo aplica si visualStrategy es 'api_generated'. + * Las vistas pueden override este valor. + */ + apiConfig?: APIConfig; + + /** + * Imagen de fallback global. + * Usada si falla la generación de cualquier vista. + * Las vistas pueden override este valor. + * + * Ejemplo: '/renders/fallback.jpg' + */ + fallbackImage?: string; +} + +// ============================================================================ +// CONFIGURABLE OBJECT +// ============================================================================ +/** + * Objeto configurable (producto). + * + * Ejemplos: casa, coche, mueble, laptop, etc. + */ export interface ConfigurableObject { - id : ObjectID; - name : I18nString; - description : I18nString; - attributes : Attribute[]; - sections : Record; + id: ObjectID; + name: I18nString; + description: I18nString; + + /** + * ✅ ESTRATEGIA DE RENDERIZADO para TODO el objeto. + * + * Esta estrategia aplica a TODAS las vistas de TODAS las secciones. + * No se puede tener diferentes estrategias por sección/vista. + * + * Si necesitas estrategias mixtas (excepcional), usa 'dynamic_image' + * y maneja la lógica en runtime. + */ + visualStrategy: RenderingStrategy; + + /** + * ✅ CONFIGURACIÓN VISUAL GLOBAL (fallback). + * + * Valores por defecto que heredan todas las vistas + * si no especifican sus propios valores. + * + * Opcional si todas las vistas definen sus propios valores. + */ + visualConfig?: GlobalVisualConfig; + + /** + * Atributos GLOBALES del objeto. + * Aplican a todo el objeto independientemente de la sección. + * + * Ejemplo: 'calidad de acabados', 'color principal' + */ + attributes: Attribute[]; + + /** + * Secciones del objeto. + * + * Ejemplo para una casa: + * - 'sc:salon' + * - 'sc:bano' + * - 'sc:dormitorio' + */ + sections: Record; + + /** + * Orden de presentación de secciones en UI. + * Si no se especifica, el orden es arbitrario. + */ sectionOrder?: SectionID[]; - category? : string; - metadata? : Metadata; -} + + /** + * Categoría del objeto (opcional). + * + * Ejemplo: 'residencial', 'automovil', 'mobiliario' + */ + category?: string; + + /** Metadata adicional */ + metadata?: Metadata; +} \ No newline at end of file diff --git a/src/types/model/section.types.ts b/src/types/model/section.types.ts index 8ad83df..2986f28 100644 --- a/src/types/model/section.types.ts +++ b/src/types/model/section.types.ts @@ -1,79 +1,84 @@ /** * ============================================================================ - * SECTION TYPES + * SECTION TYPES - VERSIÓN FINAL SIMPLIFICADA * ============================================================================ + * + * Dos tipos de secciones: + * 1. Con visualización → DEBE tener views + * 2. Sin visualización → NO tiene views (configuración pura) */ + import type { I18nString } from '../i18n'; -import type {APIConfig, Metadata} from '../core'; +import type { APIConfig, Metadata } from '../core'; import type { AttrID, SectionID, + ViewID, Attribute, - JsonLogic } from '@/types'; + JsonLogic +} from '@/types'; // ============================================================================ -// RENDERING STRATEGY +// VIEW VISUAL CONFIG // ============================================================================ -export type RenderingStrategy = - | 'static_image' - | 'dynamic_image' - | 'api_generated' - | 'three_d' - | 'composite_layers' - | 'none'; - -// ============================================================================ -// VISUAL CONFIG -// ============================================================================ +export interface ViewVisualConfig { + /** + * Template para URL de imagen + * Placeholders: {basePath}, {section}, {view}, {at_} + */ + imageUrlTemplate?: string; + /** Ruta base (puede heredarse del objeto) */ + basePath?: string; + /** Atributos GLOBALES que afectan esta vista */ + globalAttrDependencies?: AttrID[]; -export interface SectionVisualConfig { - strategy : RenderingStrategy; - - /** Template para URL estática */ - imageUrlTemplate? : string; - - /** Atributos globales que afectan la imagen */ - globalAttrDependencies? : AttrID[]; - - /** Atributos de sección que afectan la imagen */ + /** Atributos DE SECCIÓN que afectan esta vista */ sectionAttrDependencies?: AttrID[]; - - /** Ruta base */ - basePath? : string; - - /** Config para generación async via API */ - //TODO sacar como tipo - apiConfig? : APIConfig; - + + /** Config API (puede heredarse del objeto) */ + apiConfig?: APIConfig; + /** Config 3D */ threeDConfig?: { - modelUrl : string; + modelUrl: string; textureAttrs: Array<{ - attrId : AttrID; - scope : 'global' | 'section'; + attrId: AttrID; + scope: 'global' | 'section'; }>; }; - - /** Imagen de fallback si falla generación */ + + /** Imagen de fallback */ fallbackImage?: string; } +// ============================================================================ +// SECTION VIEW +// ============================================================================ + +export interface SectionView { + id: ViewID; + name: I18nString; + description?: I18nString; + visualConfig: ViewVisualConfig; + order?: number; + icon?: string; +} + // ============================================================================ // SECTION AVAILABILITY // ============================================================================ export interface SectionAvailability { - mode : 'required' | 'optional' | 'conditional'; - condition? : JsonLogic; - dependsOn? : AttrID[]; - //TODO sacar como tipos + mode: 'required' | 'optional' | 'conditional'; + condition?: JsonLogic; + dependsOn?: AttrID[]; onDeactivate?: { - action : 'clear' | 'preserve' | 'reset'; + action: 'clear' | 'preserve' | 'reset'; confirmWithUser?: boolean; - confirmMessage? : I18nString; + confirmMessage?: I18nString; }; } @@ -81,14 +86,55 @@ export interface SectionAvailability { // SECTION // ============================================================================ +/** + * Sección de un objeto configurable. + * + * REGLAS: + * + * 1. SECCIÓN CON VISUALIZACIÓN: + * - DEBE tener 'views' con al menos una vista + * - DEBE tener 'defaultView' + * - Genera imágenes propias + * + * 2. SECCIÓN SIN VISUALIZACIÓN: + * - NO tiene 'views' ni 'defaultView' + * - Solo configura opciones sin afectar imagen + * - Ejemplo: garantía, seguros, servicios + * + * NO hay delegación de vistas. Si un atributo afecta visualización, + * debe estar en la sección que se visualiza. + */ export interface Section { - id : SectionID; - name : I18nString; - description : I18nString; - attrs : Attribute[]; - visualConfig: SectionVisualConfig; + id: SectionID; + name: I18nString; + description: I18nString; + + /** Atributos de esta sección */ + attrs: Attribute[]; + + /** + * Vistas de esta sección (OPCIONAL). + * + * - Si está presente: DEBE tener al menos una vista y defaultView + * - Si NO está presente: Esta sección no se visualiza + */ + views?: Record; + + /** + * Vista por defecto (OPCIONAL pero REQUERIDO si hay views). + * DEBE existir en 'views'. + */ + defaultView?: ViewID; + + /** Disponibilidad */ availability: SectionAvailability; - order? : number; - icon? : string; - metadata? : Metadata; -} + + /** Orden en UI */ + order?: number; + + /** Icono para UI */ + icon?: string; + + /** Metadata */ + metadata?: Metadata; +} \ No newline at end of file diff --git a/src/types/view/view.types.ts b/src/types/view/view.types.ts index 2282da0..a211581 100644 --- a/src/types/view/view.types.ts +++ b/src/types/view/view.types.ts @@ -38,7 +38,6 @@ export interface Hotspot { /** Nombre descriptivo */ name: I18nString; - /** Posición X en porcentaje (0-100) */ x: number; diff --git a/src/utils/validation/section.validator.ts b/src/utils/validation/section.validator.ts index 1338095..cfd9045 100644 --- a/src/utils/validation/section.validator.ts +++ b/src/utils/validation/section.validator.ts @@ -1,41 +1,363 @@ /** * ============================================================================ - * SECTION VALIDATOR + * SECTION VALIDATOR - UPDATED * ============================================================================ + * + * Valida secciones con la nueva arquitectura: + * - views opcionales + * - strategy a nivel de objeto + * - validación de coherencia */ -import type { Section, AttrID } from '@/types'; -import type { ValidationError } from '@/utils'; -import {ERRORS} from "@/messages"; - -/** - * Validar sección en runtime - */ -export function validateSection(section: Section): ValidationError[] { - const errors: ValidationError[] = []; - - // Validar que imageUrlTemplate tiene placeholders válidos - if (section.visualConfig.imageUrlTemplate) { - const template = section.visualConfig.imageUrlTemplate; - const placeholders = template.match(/\{[^}]+}/g) || []; - - placeholders.forEach(placeholder => { - const cleanName = placeholder.slice(1, -1); // Remove { } - - if (cleanName.startsWith('global.')) { - const attrName = cleanName.replace('global.', ''); - const attrId = `at:${attrName}` as AttrID; - - if (!section.visualConfig.globalAttrDependencies?.includes(attrId)) { - errors.push({ - sectionId: section.id, - message: ERRORS.PLACEHOLDER_NOT_IN_DEPENDENCIES(placeholder, section.id), - severity: 'error' - }); - } +import type { + Section, + SectionView, + ConfigurableObject, + AttrID, + ViewID, + I18nString, + SupportedLocale, + Severity +} from '@/types'; +import { VALIDATION_SECTION, VALIDATION_OBJECT } from '@/messages/validation-section'; +import { translate } from '@/utils/i18n/translation.utils'; + +// ============================================================================ +// VALIDATION ERROR +// ============================================================================ + +export interface SectionValidationError { + sectionId : string; + viewId? : ViewID; + message : I18nString; + severity : Severity; +} + +// ============================================================================ +// SECTION VALIDATION +// ============================================================================ + +/** + * Valida una sección individual + */ +export function validateSection(section: Section): SectionValidationError[] { + const errors: SectionValidationError[] = []; + + // 1. Coherencia views <-> defaultView + const hasViews = section.views && Object.keys(section.views).length > 0; + const hasDefaultView = section.defaultView !== undefined; + + if (hasViews && !hasDefaultView) { + errors.push({ + sectionId: section.id, + message: VALIDATION_SECTION.HAS_VIEWS_NO_DEFAULT(), + severity: 'error' + }); + } + + if (!hasViews && hasDefaultView) { + errors.push({ + sectionId: section.id, + message: VALIDATION_SECTION.HAS_DEFAULT_NO_VIEWS(), + severity: 'error' + }); + } + + if (hasViews && hasDefaultView) { + // Verificar que defaultView existe en views + if (!section.views![section.defaultView!]) { + errors.push({ + sectionId: section.id, + message: VALIDATION_SECTION.DEFAULT_VIEW_NOT_FOUND(section.defaultView!), + severity: 'error' + }); + } + + // Validar cada vista + for (const view of Object.values(section.views!)) { + errors.push(...validateView(section, view)); + } + } + + // 2. Coherencia affectsVisual <-> views + const hasVisualAttrs = section.attrs.some(attr => + attr.display?.affectsVisual === true + ); + + if (hasVisualAttrs && !hasViews) { + errors.push({ + sectionId: section.id, + message: VALIDATION_SECTION.VISUAL_ATTRS_NO_VIEWS(), + severity: 'warning' + }); + } + + // 3. Validar IDs únicos de atributos + const attrIds = new Set(); + for (const attr of section.attrs) { + if (attrIds.has(attr.id)) { + errors.push({ + sectionId: section.id, + message: VALIDATION_SECTION.DUPLICATE_ATTRIBUTE_ID(attr.id), + severity: 'error' + }); + } + attrIds.add(attr.id); + } + + return errors; +} + +/** + * Valida una vista individual + */ +function validateView( + section : Section, + view : SectionView +): SectionValidationError[] { + const errors: SectionValidationError[] = []; + + const config = view.visualConfig; + + // 1. Validar imageUrlTemplate si existe + if (config.imageUrlTemplate) { + errors.push(...validateImageTemplate(section, view)); + } + + // 2. Validar dependencies + if (config.sectionAttrDependencies) { + const sectionAttrIds = new Set(section.attrs.map(a => a.id)); + + for (const attrId of config.sectionAttrDependencies) { + if (!sectionAttrIds.has(attrId)) { + errors.push({ + sectionId: section.id, + viewId: view.id, + message: VALIDATION_SECTION.DEPENDENCY_NOT_IN_SECTION(attrId), + severity: 'error' + }); + } + } + } + + return errors; +} + +/** + * Valida el template de URL de imagen + */ +function validateImageTemplate( + section : Section, + view : SectionView +): SectionValidationError[] { + const errors: SectionValidationError[] = []; + const template = view.visualConfig.imageUrlTemplate!; + + // Extraer placeholders: {basePath}, {section}, {view}, {at_suelo} + const placeholders = template.match(/\{[^}]+}/g) || []; + const sectionAttrIds = new Set(section.attrs.map(a => a.id)); + + for (const placeholder of placeholders) { + const cleanName = placeholder.slice(1, -1); // Quitar { } + + // Placeholders reservados + if (['basePath', 'section', 'view'].includes(cleanName)) { + continue; // OK + } + + // Placeholders de atributos: {at_suelo} + if (cleanName.startsWith('at_')) { + const attrId = cleanName.replace('at_', 'at:') as AttrID; + + // ¿Está en dependencies de sección? + const inDeps = view.visualConfig.sectionAttrDependencies?.includes(attrId); + + // ¿Existe en la sección? + const exists = sectionAttrIds.has(attrId); + + if (!exists) { + errors.push({ + sectionId: section.id, + viewId: view.id, + message: VALIDATION_SECTION.TEMPLATE_ATTR_NOT_IN_SECTION(attrId), + severity: 'error' + }); + } else if (!inDeps) { + errors.push({ + sectionId: section.id, + viewId: view.id, + message: VALIDATION_SECTION.TEMPLATE_ATTR_NOT_IN_DEPS(attrId), + severity: 'warning' + }); } + } + // Placeholder desconocido + else { + errors.push({ + sectionId: section.id, + viewId: view.id, + message: VALIDATION_SECTION.UNKNOWN_PLACEHOLDER(placeholder), + severity: 'warning' + }); + } + } + + return errors; +} + +// ============================================================================ +// OBJECT VALIDATION +// ============================================================================ + +/** + * Valida un objeto completo y todas sus secciones + */ +export function validateObject(object: ConfigurableObject): SectionValidationError[] { + const errors: SectionValidationError[] = []; + + // 1. Validar estrategia visual + if (!object.visualStrategy) { + errors.push({ + sectionId: object.id, + message: VALIDATION_OBJECT.MISSING_VISUAL_STRATEGY(), + severity: 'error' + }); + } + + // 2. Validar cada sección + for (const section of Object.values(object.sections)) { + errors.push(...validateSection(section)); + } + + // 3. Validar coherencia visualStrategy <-> views + const hasVisualSections = Object.values(object.sections).some(s => + s.views && Object.keys(s.views).length > 0 + ); + + if (hasVisualSections && object.visualStrategy === 'none') { + errors.push({ + sectionId: object.id, + message: VALIDATION_OBJECT.VISUAL_SECTIONS_WITH_NONE_STRATEGY(), + severity: 'warning' }); } - + + // 4. Validar basePath según estrategia + const needsBasePath = ['static_image', 'dynamic_image'].includes(object.visualStrategy); + const hasBasePath = object.visualConfig?.basePath !== undefined; + + if (needsBasePath && !hasBasePath) { + // Verificar si alguna vista lo define + const anyViewHasBasePath = Object.values(object.sections).some(section => + section.views && Object.values(section.views).some(view => + view.visualConfig.basePath !== undefined + ) + ); + + if (!anyViewHasBasePath) { + errors.push({ + sectionId: object.id, + message: VALIDATION_OBJECT.MISSING_BASE_PATH(object.visualStrategy), + severity: 'warning' + }); + } + } + + // 5. Validar apiConfig según estrategia + if (object.visualStrategy === 'api_generated') { + const hasApiConfig = object.visualConfig?.apiConfig !== undefined; + + if (!hasApiConfig) { + // Verificar si alguna vista lo define + const anyViewHasApiConfig = Object.values(object.sections).some(section => + section.views && Object.values(section.views).some(view => + view.visualConfig.apiConfig !== undefined + ) + ); + + if (!anyViewHasApiConfig) { + errors.push({ + sectionId: object.id, + message: VALIDATION_OBJECT.MISSING_API_CONFIG(), + severity: 'error' + }); + } + } + } + + // 6. Validar sectionOrder + if (object.sectionOrder) { + const sectionIds = new Set(Object.keys(object.sections)); + + for (const sectionId of object.sectionOrder) { + if (!sectionIds.has(sectionId)) { + errors.push({ + sectionId: object.id, + message: VALIDATION_OBJECT.SECTION_ORDER_UNKNOWN_SECTION(sectionId), + severity: 'error' + }); + } + } + } + return errors; } + +// ============================================================================ +// HELPERS +// ============================================================================ + +/** + * Agrupa errores por severidad + */ +export function groupBySeverity(errors: SectionValidationError[]): { + errors : SectionValidationError[]; + warnings: SectionValidationError[]; + info : SectionValidationError[]; +} { + return { + errors: errors.filter(e => e.severity === 'error'), + warnings: errors.filter(e => e.severity === 'warning'), + info: errors.filter(e => e.severity === 'info') + }; +} + +/** + * Verifica si hay errores críticos + */ +export function hasErrors(errors: SectionValidationError[]): boolean { + return errors.some(e => e.severity === 'error'); +} + +/** + * Formatea errores para mostrar + */ +export function formatErrors( + errors: SectionValidationError[], + locale: SupportedLocale = 'es' +): string { + const grouped = groupBySeverity(errors); + const lines: string[] = []; + + if (grouped.errors.length > 0) { + lines.push('ERRORS:'); + grouped.errors.forEach(e => { + const location = e.viewId + ? `${e.sectionId} > ${e.viewId}` + : e.sectionId; + lines.push(` ❌ [${location}] ${translate(e.message, locale)}`); + }); + } + + if (grouped.warnings.length > 0) { + lines.push('WARNINGS:'); + grouped.warnings.forEach(e => { + const location = e.viewId + ? `${e.sectionId} > ${e.viewId}` + : e.sectionId; + lines.push(` ⚠️ [${location}] ${translate(e.message, locale)}`); + }); + } + + return lines.join('\n'); +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 2fd2273..ac53799 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,11 +4,13 @@ "outDir": "./dist", "baseUrl": "./src", "paths": { - "@/types/*": ["types/*"], - "@/utils/*": ["utils/*"], - "@/engine/*": ["engine/*"], - "@/constants/*": ["constants/*"], - "@/*": ["*"] + "@/types/*" : ["types/*"], + "@/utils/*" : ["utils/*"], + "@/engine/*" : ["engine/*"], + "@/constants/*" : ["constants/*"], + "@/adapters/*" : ["adapters/*"], + "@/svelte/*" : ["adapters/svelte/*"], + "@/*" : ["*"] }, // ✅ Opciones CRÍTICAS para usar .ts en imports: