svelte adapters first aprox

master
dev 8 months ago
parent 4c0e0a33f0
commit 9bf050f990

@ -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<AttrID, OptionID>`
- 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
<script>
import Configurator from '@/adapters/svelte/components/Configurator.svelte';
import { CATALOGO_VIVIENDAS } from './fixtures';
</script>
<Configurator
catalog={CATALOGO_VIVIENDAS}
objectId="ob:apartamento"
/>
```
---
## 🔄 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
<script lang="ts">
import { createConfigurationStore } from '@/adapters/svelte/stores';
const config = createConfigurationStore({
catalog: MY_CATALOG,
objectId: 'ob:producto'
});
// Reactivo automático
$: selection = $config.selection;
$: isValid = $config.isValid;
$: violations = $config.validation?.violations || [];
</script>
<!-- UI reactiva -->
<div>
<h1>{$config.object.name.es}</h1>
<select on:change={(e) => config.selectOption('at:color', e.target.value)}>
<option value="">Seleccionar color</option>
<option value="op:rojo">Rojo</option>
<option value="op:azul">Azul</option>
</select>
{#if !isValid}
<div class="errors">
{#each violations as v}
<p>{v.rule.message.es}</p>
{/each}
</div>
{/if}
<button disabled={!isValid}>
Confirmar
</button>
</div>
```
---
## 🚀 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

@ -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<OptionID, OptionDefinition>;
objects : Record<ObjectID, ConfigurableObject>;
rules? : Record<RuleID, ValidationRule>;
}
```
**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<SectionID, Section>;
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)

@ -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

@ -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<ViewID, SectionView>;
// ✅ 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_<id>}` | 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)

@ -0,0 +1,346 @@
<!--
============================================================================
CONFIGURATOR - Example Svelte Component
============================================================================
Ejemplo de cómo usar el ConfigurationStore en un componente Svelte.
-->
<script lang="ts">
import { createConfigurationStore } from '@/svelte/stores';
import type { ConfigurationCatalog } from '@/types';
// Props
export let catalog: ConfigurationCatalog;
export let objectId: string;
// Crear el store
const config = createConfigurationStore({
catalog,
objectId,
autoValidate: true
});
// Acceso reactivo al estado
$: currentSection = $config.currentSection;
$: currentView = $config.currentView;
$: selection = $config.selection;
$: validation = $config.validation;
$: isValid = $config.isValid;
// Handlers
function handleOptionSelect(attrId: string, optionId: string) {
config.selectOption(attrId, optionId);
}
function handleSectionChange(sectionId: string) {
config.setCurrentSection(sectionId);
}
function handleViewChange(viewId: string) {
config.setCurrentView(viewId);
}
function handleReset() {
config.reset();
}
function handleUndo() {
config.undo();
}
</script>
<div class="configurator">
<!-- Header -->
<header class="configurator-header">
<h1>{$config.object.name.es}</h1>
<p>{$config.object.description.es}</p>
<div class="actions">
<button on:click={handleUndo}>
⬅️ Deshacer
</button>
<button on:click={handleReset}>
🔄 Resetear
</button>
<button disabled={!isValid}>
✅ Confirmar configuración
</button>
</div>
</header>
<div class="configurator-content">
<!-- Section Navigation -->
<nav class="section-nav">
<h2>Secciones</h2>
<ul>
{#each Object.values($config.object.sections) as section}
<li>
<button
class:active={section.id === currentSection?.id}
on:click={() => handleSectionChange(section.id)}
>
{section.icon || '📦'} {section.name.es}
</button>
</li>
{/each}
</ul>
</nav>
<!-- Attributes Panel -->
<div class="attributes-panel">
{#if currentSection}
<h2>{currentSection.name.es}</h2>
<p>{currentSection.description.es}</p>
<!-- Atributos de la sección -->
{#each currentSection.attrs as attr}
{#if attr.type === 'dynamic' && attr.display?.uiVisible}
<div class="attribute">
<label for={attr.id}>
{attr.name.es}
{#if config.isRequired(attr.id)}
<span class="required">*</span>
{/if}
</label>
<select
id={attr.id}
value={selection[attr.id] || ''}
on:change={(e) => handleOptionSelect(attr.id, e.target.value)}
class:error={config.hasErrorsForAttribute(attr.id)}
>
<option value="">-- Seleccionar --</option>
{#each attr.options as opt}
{@const option = catalog.options[opt.optionId]}
{#if option}
<option value={option.id}>
{option.name.es}
{#if option.pricing?.baseAmount}
(+{option.pricing.baseAmount}€)
{/if}
</option>
{/if}
{/each}
</select>
<!-- Violaciones -->
{#each config.getViolationsForAttribute(attr.id) as violation}
<div class="violation {violation.severity}">
{violation.rule.message.es}
</div>
{/each}
</div>
{/if}
{/each}
{/if}
</div>
<!-- Visual Preview -->
<div class="visual-panel">
{#if currentView}
<div class="view-selector">
<h3>Vistas</h3>
{#each Object.values(currentSection.views || {}) as view}
<button
class:active={view.id === currentView.id}
on:click={() => handleViewChange(view.id)}
>
{view.icon || '📷'} {view.name.es}
</button>
{/each}
</div>
<div class="preview">
<h3>{currentView.name.es}</h3>
<!-- Aquí iría el renderizado de la imagen -->
<div class="image-placeholder">
<p>Vista: {currentView.id}</p>
<p>Template: {currentView.visualConfig.imageUrlTemplate}</p>
</div>
</div>
{:else}
<div class="no-visual">
<p>Esta sección no tiene visualización</p>
</div>
{/if}
</div>
</div>
<!-- Validation Summary -->
{#if validation}
<footer class="validation-summary">
{#if validation.hasErrors}
<div class="errors">
❌ {validation.violations.filter(v => v.severity === 'error').length} errores
</div>
{/if}
{#if validation.hasWarnings}
<div class="warnings">
⚠️ {validation.violations.filter(v => v.severity === 'warning').length} advertencias
</div>
{/if}
{#if validation.isValid}
<div class="success">
✅ Configuración válida
</div>
{/if}
</footer>
{/if}
</div>
<style>
.configurator {
display: flex;
flex-direction: column;
height: 100vh;
font-family: system-ui, -apple-system, sans-serif;
}
.configurator-header {
padding: 1rem 2rem;
border-bottom: 1px solid #e0e0e0;
}
.actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.configurator-content {
display: grid;
grid-template-columns: 200px 1fr 1fr;
flex: 1;
overflow: hidden;
}
.section-nav {
border-right: 1px solid #e0e0e0;
padding: 1rem;
overflow-y: auto;
}
.section-nav ul {
list-style: none;
padding: 0;
}
.section-nav button {
width: 100%;
text-align: left;
padding: 0.75rem;
border: none;
background: transparent;
cursor: pointer;
}
.section-nav button:hover {
background: #f5f5f5;
}
.section-nav button.active {
background: #007bff;
color: white;
}
.attributes-panel {
padding: 2rem;
overflow-y: auto;
}
.attribute {
margin-bottom: 1.5rem;
}
.attribute label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
}
.required {
color: red;
}
.attribute select {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.attribute select.error {
border-color: red;
}
.violation {
margin-top: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
}
.violation.error {
background: #fee;
color: #c00;
}
.violation.warning {
background: #ffc;
color: #840;
}
.visual-panel {
padding: 2rem;
background: #f9f9f9;
overflow-y: auto;
}
.view-selector {
margin-bottom: 1rem;
}
.view-selector button {
margin-right: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid #ccc;
background: white;
cursor: pointer;
}
.view-selector button.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.image-placeholder {
background: #ddd;
padding: 2rem;
text-align: center;
border-radius: 8px;
}
.validation-summary {
display: flex;
gap: 1rem;
padding: 1rem 2rem;
border-top: 1px solid #e0e0e0;
}
.errors {
color: #c00;
font-weight: 600;
}
.warnings {
color: #840;
font-weight: 600;
}
.success {
color: #080;
font-weight: 600;
}
</style>

@ -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<typeof state.toJSON>) => {
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<typeof createConfigurationStore>
): Readable<Section | null> {
return derived(configStore, $config => $config.currentSection);
}
/**
* Derived store para la vista actual
*/
export function deriveCurrentView(
configStore: ReturnType<typeof createConfigurationStore>
): Readable<SectionView | null> {
return derived(configStore, $config => $config.currentView);
}
/**
* Derived store para validación
*/
export function deriveValidation(
configStore: ReturnType<typeof createConfigurationStore>
): Readable<ValidationResult | null> {
return derived(configStore, $config => $config.validation);
}
/**
* Derived store para verificar si es válido
*/
export function deriveIsValid(
configStore: ReturnType<typeof createConfigurationStore>
): Readable<boolean> {
return derived(configStore, $config => $config.isValid);
}
/**
* Derived store para selección
*/
export function deriveSelection(
configStore: ReturnType<typeof createConfigurationStore>
): Readable<SelectionStateData> {
return derived(configStore, $config => $config.selection);
}
// ============================================================================
// TYPE EXPORTS
// ============================================================================
export type ConfigurationStore = ReturnType<typeof createConfigurationStore>;

@ -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;

@ -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<AttrID, OptionID> {
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<typeof this.toJSON>): 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();
}
}

@ -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<AttrID, OptionID>;
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<SelectionStateData>): 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<SelectionStateData> {
const result: Partial<SelectionStateData> = {};
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);
}
}

@ -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<AttrID, Violation[]>;
}
/**
* 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<AttrID, Violation[]>();
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;
}
}

@ -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<SectionID, Section>;
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<SectionID, Section>;
/**
* 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;
}

@ -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_<attrId>}
*/
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<ViewID, SectionView>;
/**
* 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;
}

@ -38,7 +38,6 @@ export interface Hotspot {
/** Nombre descriptivo */
name: I18nString;
/** Posición X en porcentaje (0-100) */
x: number;

@ -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<AttrID>();
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');
}

@ -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:

Loading…
Cancel
Save

Powered by TurnKey Linux.