First Commit

master
dev 7 months ago
parent c81ab33d66
commit f0ba99b7dd

@ -0,0 +1,5 @@
export * from './messages.ts';

@ -0,0 +1,783 @@
# VICE — Guía de configuración de catálogos
Esta guía explica cómo modelar un catálogo configurable completo usando el sistema VICE.
Cubre todos los conceptos del sistema con ejemplos tomados directamente del catálogo de viviendas.
---
## Índice
1. [Conceptos fundamentales](#conceptos-fundamentales)
2. [Opciones](#opciones)
3. [Atributos](#atributos)
4. [Secciones](#secciones)
5. [Vistas y templates visuales](#vistas-y-templates-visuales)
6. [Objetos configurables](#objetos-configurables)
7. [Reglas de validación](#reglas-de-validación)
8. [Precios](#precios)
9. [El catálogo completo](#el-catálogo-completo)
10. [Uso con ConfigurationEngine](#uso-con-configurationengine)
---
## Conceptos fundamentales
El sistema VICE modela productos configurables en torno a cinco entidades:
```
Catálogo
└─ Objetos (modelos configurables: Apartamento, Dúplex, Coche...)
├─ Atributos globales (afectan a todo el objeto: Calidad)
└─ Secciones (partes configurables: Salón, Baño, Cocina...)
├─ Atributos locales (Suelo, Pared, Encimera...)
└─ Vistas (imágenes que se actualizan con las selecciones)
```
**IDs** — todos los IDs siguen una convención con prefijo de tipo:
| Prefijo | Tipo | Ejemplo |
|---------|------------|----------------------|
| `op:` | Opción | `op:suelo_parquet` |
| `at:` | Atributo | `at:suelo` |
| `sc:` | Sección | `sc:salon` |
| `vw:` | Vista | `vw:front` |
| `ob:` | Objeto | `ob:apartamento` |
| `rl:` | Regla | `rl:estandar_no_lujo`|
| `ct:` | Catálogo | `ct:viviendas` |
---
## Opciones
Las opciones son los valores que el usuario puede elegir. Se definen a nivel de catálogo y se reutilizan en múltiples atributos y objetos.
### Opción con precio fijo
```typescript
[OPT_SUELO_PARQUET]: {
id : 'op:suelo_parquet',
code : 'parquet', // usado en templates de imagen
name : { es: 'Parquet', en: 'Parquet' },
description: { es: 'Suelo de madera natural', en: 'Natural wood floor' },
pricing : { baseAmount: 45 }, // 45€ fijo
tags : ['madera', 'calido'], // opcional — para filtros en UI
},
```
### Opción sin coste extra
```typescript
[OPT_PARED_BLANCO]: {
id : 'op:pared_blanco',
code : 'blanco',
name : { es: 'Blanco roto', en: 'Off white' },
pricing: { baseAmount: 0 }, // incluido en el precio base
},
```
### Opción con precio dinámico (expresión JsonLogic)
Para opciones cuantificables donde el precio depende de la cantidad seleccionada:
```typescript
[OPT_ARMARIO_LACADO]: {
id : 'op:armario_lacado',
code : 'lacado',
name : { es: 'Lacado blanco', en: 'White lacquered' },
pricing: {
baseAmount: 0,
// Descuento por volumen: 2 puertas → 350€/u, 3 puertas → 300€/u
dynamicExpression: {
'if': [
{ '>=': [{ var: 'quantity' }, 3] },
{ '*': [{ var: 'quantity' }, 300] }, // 3 puertas = 900€
{ '*': [{ var: 'quantity' }, 350] } // 2 puertas = 700€
]
}
}
},
```
El contexto disponible en `dynamicExpression` es `{ quantity, baseAmount, attributes }`.
### Precio de calidad (precio global del modelo)
La calidad es un atributo global que añade un precio base a toda la configuración:
```typescript
[OPT_CALIDAD_ESTANDAR]: { pricing: { baseAmount: 0 } },
[OPT_CALIDAD_PREMIUM] : { pricing: { baseAmount: 15000 } },
[OPT_CALIDAD_LUJO] : { pricing: { baseAmount: 35000 } },
```
---
## Atributos
Los atributos definen qué puede elegir el usuario. Hay tres tipos.
### `dynamic` — el usuario elige una opción
El tipo más común. El usuario selecciona una de las opciones disponibles.
```typescript
{
id : 'at:suelo',
code : 'suelo', // usado en placeholders de templates
name : { es: 'Suelo', en: 'Flooring' },
description : { es: 'Material del suelo', en: 'Floor material' },
type : 'dynamic',
dataType : 'reference',
defaultValue: 'op:suelo_ceramica', // valor inicial
options : [
{ optionId: 'op:suelo_ceramica', priority: 1 },
{ optionId: 'op:suelo_parquet', priority: 2 },
{ optionId: 'op:suelo_porcelanico', priority: 3 },
{ optionId: 'op:suelo_microcemento', priority: 4 },
],
display: {
uiVisible : true, // mostrar en configurador
affectsVisual: true, // cambiar esta opción actualiza la imagen
affectsPrice : true, // cambiar esta opción actualiza el precio
}
}
```
#### Opción cuantificable
Cuando una opción tiene cantidad asociada (ej: número de puertas), se define en `AttributeOption.quantity`. La cantidad puede variar entre opciones del mismo atributo.
```typescript
{
id : 'at:armario_puertas',
code : 'armario_puertas',
type : 'dynamic',
dataType : 'reference',
defaultValue : 'op:armario_lacado',
defaultQuantity : 2, // cantidad inicial
userConfigurableQuantity: true, // el usuario puede cambiar la cantidad
options: [
{
optionId : 'op:armario_lacado',
priority : 1,
quantity : { default: 2, min: 2, max: 3, step: 1,
unit: { es: 'puertas', en: 'doors' } },
// override de dynamicExpression para este atributo específico
// tiene precedencia sobre el pricing de la OptionDefinition
dynamicExpression: {
'if': [
{ '>=': [{ var: 'quantity' }, 3] },
{ '*': [{ var: 'quantity' }, 300] },
{ '*': [{ var: 'quantity' }, 350] }
]
}
},
{
optionId : 'op:armario_madera',
priority : 2,
quantity : { default: 2, min: 2, max: 3, step: 1,
unit: { es: 'puertas', en: 'doors' } },
dynamicExpression: { '*': [{ var: 'quantity' }, 500] }
},
],
display: { uiVisible: true, affectsVisual: true, affectsPrice: true }
}
```
> **Nota de diseño**: la cantidad vive en la `AttributeOption`, no en el `Attribute`. Esto permite que distintas opciones del mismo atributo tengan rangos de cantidad diferentes, o que solo algunas sean cuantificables.
### `fixed` — valor constante, no configurable
Para datos informativos como dimensiones. No aparece en el configurador pero puede usarse en expresiones de precio.
```typescript
{
id : 'at:m2_cocina',
code : 'm2_cocina',
name : { es: 'Superficie', en: 'Surface' },
type : 'fixed',
dataType: 'number',
value : 10, // 10 m²
unit : { es: 'm²', en: 'sqm' },
display : { uiVisible: false, readonly: true }
}
```
### `computed` — calculado a partir de otros atributos
Para valores derivados que no edita el usuario. Se calcula con JsonLogic.
```typescript
{
id : 'at:precio_suelo_total',
type : 'computed',
dataType : 'number',
expression : { '*': [{ var: 'at_precio_m2' }, { var: 'at_m2_salon' }] },
dependencies: ['at:precio_m2', 'at:m2_salon'],
display : { uiVisible: false }
}
```
### Atributos globales vs locales
Un **atributo global** se define en `ConfigurableObject.attributes` y afecta a todo el objeto. Los atributos locales se definen en `Section.attributes` y solo afectan a esa sección.
```typescript
// Atributo global — calidad afecta a todo el apartamento
const APARTAMENTO = {
attributes: [attrCalidadGlobal], // 'at:calidad'
sections: {
salon : { attributes: [attrSuelo(), attrPared()] },
bano : { attributes: [attrSanitario, attrRevestBano] },
cocina : { attributes: [attrEncimera, attrMuebleCocina, attrSuelo()] },
}
};
```
> El mismo atributo puede aparecer en varias secciones (ej: `at:suelo` está en salón, dormitorio y cocina). El sistema deduplica automáticamente — el precio se suma una sola vez.
---
## Secciones
Las secciones agrupan los atributos configurables de una parte del producto.
```typescript
const seccionSalon = (): VisualSection => ({
kind : 'visual', // siempre 'visual' por ahora
id : 'sc:salon',
code : 'salon', // usado en templates
name : { es: 'Salón', en: 'Living room' },
description: { es: 'Salón-comedor', en: 'Living-dining room' },
attributes : [attrSuelo(), attrPared()],
views : {
'vw:front': viewFront('{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg'),
'vw:360' : view360 ('{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg'),
},
defaultView : 'vw:front',
availability: { mode: 'required' }, // 'required' | 'optional' | 'conditional'
});
```
### Disponibilidad de sección
| Modo | Descripción |
|---------------|----------------------------------------------------------|
| `required` | Siempre visible y activa |
| `optional` | El usuario puede activarla o no (ej: armario) |
| `conditional` | Se activa cuando un atributo tiene un valor específico |
---
## Vistas y templates visuales
Las vistas definen cómo se renderiza visualmente una sección. El sistema soporta cuatro estrategias.
### `static_image` — imagen estática con template
La más común. El template se resuelve sustituyendo placeholders por los valores actuales.
```typescript
visualConfig: {
strategy : 'static_image',
template : '{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg',
basePath : '/renders/vivienda/', // prefijo de la URL final
fallbackImage: '/renders/fallback.jpg', // imagen si algo falla
}
```
#### Placeholders disponibles
| Placeholder | Se sustituye por |
|-------------------|-------------------------------------------------------|
| `{object}` | `code` del objeto activo (ej: `apartamento`) |
| `{section}` | `code` de la sección (ej: `salon`) |
| `{view}` | `code` de la vista (ej: `front`, `360`) |
| `{at:suelo}` | `code` de la opción seleccionada para `at:suelo` |
| `{at:pared}` | `code` de la opción seleccionada para `at:pared` |
#### Ejemplo de URL resuelta
Con el estado `{ suelo: 'op:suelo_parquet', pared: 'op:pared_blanco' }`:
```
template : '{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg'
basePath : '/renders/vivienda/'
resultado: /renders/vivienda/apartamento/salon/front/parquet_blanco.jpg
```
#### `globalAttrDependencies`
Atributos globales que afectan a esta vista aunque no aparezcan en el template. Sirve para que el sistema sepa que debe re-renderizar cuando cambian.
```typescript
viewFront(
'{object}/{section}/{view}/{at:suelo}_{at:pared}.jpg',
[ATTR_CALIDAD] // la calidad afecta visualmente aunque no esté en el template
)
```
### `composite_layers` — capas superpuestas
Para renders que se componen de varias imágenes apiladas (ej: suelo + paredes + muebles como capas independientes):
```typescript
visualConfig: {
strategy: 'composite_layers',
layers : [
{ attrId: 'at:suelo', template: '{object}/{section}/{view}/suelo_{at:suelo}.png', zIndex: 0 },
{ attrId: 'at:pared', template: '{object}/{section}/{view}/pared_{at:pared}.png', zIndex: 1 },
{ attrId: 'at:mueble', template: '{object}/{section}/{view}/mueble_{at:mueble}.png', zIndex: 2,
optional: true }, // si no hay valor, se omite la capa
],
basePath : '/renders/vivienda/',
fallbackImage: '/renders/fallback.jpg',
}
```
El resolver devuelve las URLs separadas por `|` para que la UI las superponga:
```
/renders/vivienda/salon/front/suelo_parquet.png|/renders/vivienda/salon/front/pared_blanco.png
```
### `api_generated` — imagen generada por API
Para renders fotorrealistas generados en tiempo real por un servicio externo:
```typescript
visualConfig: {
strategy : 'api_generated',
apiConfig: {
endpoint: 'https://renders.api.com/generate',
method : 'POST',
timeout : 5000,
},
// Parámetros a enviar — si no se especifica, envía todos los atributos con affectsVisual: true
params : ['object', 'section', 'view', 'at:suelo', 'at:pared'],
fallbackImage: '/renders/fallback.jpg',
}
```
Uso con `resolveViewApi`:
```typescript
const apiParams = engine.resolveViewApi('sc:salon', 'vw:front');
// apiParams: { endpoint, method, params: { object: 'apartamento', suelo: 'parquet', ... } }
```
### `three_d` — modelo 3D
La UI gestiona las texturas directamente. El resolver devuelve URL vacía.
```typescript
visualConfig: {
strategy: 'three_d',
modelUrl: '/models/salon.glb',
}
```
---
## Objetos configurables
Un objeto es el producto que el usuario configura (Apartamento, Dúplex, coche, etc.).
```typescript
export const APARTAMENTO: ConfigurableObject = {
id : 'ob:apartamento',
code : 'apartamento',
name : { es: 'Apartamento', en: 'Apartment' },
description: { es: 'Apartamento de 1 dormitorio (55 m²)', en: '1 bedroom apartment (55 sqm)' },
// Atributos globales — afectan a todo el objeto
attributes : [attrCalidadGlobal],
// Secciones configurables
sections : {
'sc:salon' : seccionSalon(),
'sc:bano' : seccionBano(6), // 6 m²
'sc:dormitorio1': seccionDormitorio('sc:dormitorio1', 'dormitorio', 'Dormitorio', 14),
'sc:cocina' : seccionCocina(),
'sc:armario' : seccionArmario(), // sección opcional
},
// Orden en el que aparecen las secciones en la UI
sectionOrder: ['sc:salon', 'sc:cocina', 'sc:dormitorio1', 'sc:bano', 'sc:armario'],
// basePath para templates de imagen — se hereda en secciones y vistas
basePath : '/renders/vivienda/',
category : 'residencial',
};
```
### Herencia de `basePath`
El `basePath` se resuelve en cascada: vista > sección > objeto > catálogo. El más específico gana.
```
Catálogo basePath: '/renders/'
└─ Objeto basePath: '/renders/vivienda/' ← sobreescribe al catálogo
└─ Vista basePath: '/renders/especial/' ← sobreescribe al objeto
```
---
## Reglas de validación
Las reglas controlan qué combinaciones de opciones son válidas. El sistema es **preventivo**: el usuario nunca puede llegar a un estado inválido — las opciones incompatibles se deshabilitan antes de que pueda seleccionarlas.
Las reglas se evalúan con **JsonLogic** — permiten condiciones sobre cualquier combinación de atributos.
### Estructura de una regla
```typescript
{
id : 'rl:estandar_no_lujo',
name : { es: 'Estándar sin lujo', en: 'Standard without luxury' },
condition: { '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_estandar'] },
action : {
type : 'forbid', // acción a aplicar
targetAttr: 'at:revest_bano', // atributo afectado
values : ['op:revest_marmol'] // opciones afectadas
},
priority : 9, // mayor prioridad se evalúa primero
severity : 'error', // 'error' | 'warning' | 'info'
affects : ['at:calidad', 'at:revest_bano'],
message : { es: 'El mármol no está disponible en calidad estándar', en: '...' }
}
```
> **Importante**: en el `condition`, los IDs de atributos usan guión bajo en lugar de dos puntos: `at:calidad` → `attributes.at_calidad`.
### Tipos de acción
#### `forbid` — prohíbe opciones específicas
```typescript
// Calidad estándar prohíbe mármol en el baño
{
condition: { '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_estandar'] },
action : { type: 'forbid', targetAttr: 'at:revest_bano', values: ['op:revest_marmol'] }
}
```
Resultado: con calidad estándar, el mármol aparece deshabilitado con el tooltip del `message`.
#### `allow` — lista blanca de opciones permitidas
```typescript
// Con suelo de microcemento, el revestimiento del baño solo puede ser
// microcemento o cerámica (excluye mármol automáticamente)
{
condition: { '==': [{ var: 'attributes.at_suelo' }, 'op:suelo_microcemento'] },
action : {
type : 'allow',
targetAttr: 'at:revest_bano',
values : ['op:revest_microcemento', 'op:revest_ceramica']
}
}
```
Si hay varias reglas `allow` activas para el mismo atributo, se intersectan — solo se permiten los valores que aparecen en **todas** las listas blancas.
#### `require` — obliga a un valor específico
```typescript
// Calidad lujo recomienda (advierte si no) mármol en el baño
{
condition: { '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_lujo'] },
action : { type: 'require', targetAttr: 'at:revest_bano', values: ['op:revest_marmol'] },
severity : 'warning' // warning = recomienda pero no bloquea
}
```
Si `values` está vacío, solo requiere que haya algún valor seleccionado. Si `values` tiene opciones, requiere que el valor actual sea una de ellas.
#### `suggest` — sugerencia informativa
No genera violaciones, solo información para la UI:
```typescript
{
condition: { '==': [{ var: 'attributes.at_suelo' }, 'op:suelo_parquet'] },
action : { type: 'suggest', targetAttr: 'at:pared', values: ['op:pared_beige'] },
severity : 'info',
message : { es: 'El parquet combina bien con tonos beige', en: '...' }
}
```
### Severidad
| Severidad | Comportamiento |
|-----------|-------------------------------------------------------------|
| `error` | Bloquea la selección — la opción queda deshabilitada |
| `warning` | La opción se puede seleccionar pero muestra aviso |
| `info` | Solo informativo, sin restricción |
### Condiciones complejas con JsonLogic
Las condiciones pueden combinar múltiples atributos:
```typescript
// Solo activa si calidad es premium Y suelo es parquet
condition: {
'and': [
{ '==': [{ var: 'attributes.at_calidad' }, 'op:calidad_premium'] },
{ '==': [{ var: 'attributes.at_suelo' }, 'op:suelo_parquet'] }
]
}
// Activa si calidad es premium O lujo
condition: {
'in': [
{ var: 'attributes.at_calidad' },
['op:calidad_premium', 'op:calidad_lujo']
]
}
```
### Las 6 reglas del catálogo de viviendas
| Regla | Condición | Acción | Severidad |
|-------------------------|-------------------------|--------------------------------------|-----------|
| `rl:lujo_requiere_marmol` | calidad = lujo | require mármol en baño | warning |
| `rl:estandar_no_lujo` | calidad = estándar | forbid mármol en baño | error |
| `rl:microcemento_consistente`| suelo = microcemento | allow solo microcemento/cerámica baño| warning |
| `rl:madera_no_bano` | encimera = madera | forbid sanitario negro | warning |
| `rl:negro_requiere_premium` | sanitario = negro | forbid calidad estándar | error |
| `rl:estandar_no_negro` | calidad = estándar | forbid sanitario negro | error |
---
## Precios
El precio total es la suma de todos los atributos con `affectsPrice: true`.
### Precio fijo
```typescript
// La opción contribuye con su baseAmount directamente
pricing: { baseAmount: 45 } // 45€
```
### Precio dinámico con expresión
```typescript
pricing: {
baseAmount : 0,
dynamicExpression: { '*': [{ var: 'quantity' }, 500] } // 500€ × cantidad
}
```
El contexto disponible en `dynamicExpression`:
```typescript
{
quantity : number, // cantidad seleccionada (si aplica)
baseAmount: number, // baseAmount de la opción
attributes: Record<string, string> // estado completo normalizado
}
```
### Override de expresión por `AttributeOption`
Si una `AttributeOption` tiene `dynamicExpression`, tiene precedencia sobre la de `OptionDefinition`. Permite que el mismo tipo de puerta tenga precios distintos en atributos distintos:
```typescript
options: [
{
optionId : 'op:armario_lacado',
dynamicExpression: { '*': [{ var: 'quantity' }, 350] }, // este override
// tiene precedencia sobre pricing.dynamicExpression de 'op:armario_lacado'
}
]
```
### Deduplicación de atributos compartidos
Si `at:suelo` aparece en salón, dormitorio y cocina, **se suma una sola vez**. El sistema deduplica por `attrId` al calcular el precio.
### Tax
```typescript
// En el catálogo
tax: {
rate : 0.21,
included: false, // el IVA se añade al subtotal
label : { es: 'IVA', en: 'VAT' }
}
```
Si `included: true`, `taxAmount` es 0 y `total === subtotal`.
### Ejemplo de cálculo
Con el estado base del fixture (calidad estándar, cerámica, blanco, granito, muebles blancos, puerta lacada, armario lacado 2 puertas):
| Atributo | Opción | Precio |
|-----------------------|------------------|---------|
| `at:calidad` | estándar | 0 € |
| `at:suelo` | cerámica | 30 € |
| `at:pared` | blanco | 0 € |
| `at:sanitario` | blanco | 0 € |
| `at:revest_bano` | cerámica | 40 € |
| `at:encimera` | granito | 400 € |
| `at:mueble_cocina` | blanco mate | 3.500 € |
| `at:puerta` | lacada | 250 € |
| `at:armario_puertas` | lacado × 2 | 700 € |
| **Subtotal** | | **4.920 €** |
---
## El catálogo completo
La estructura final que une todo:
```typescript
export const CATALOGO_VIVIENDAS: ConfigurationCatalog = {
id : 'ct:viviendas',
code : 'viviendas',
name : { es: 'Catálogo de Viviendas', en: 'Housing Catalog' },
description: { es: 'Configurador de acabados para viviendas de obra nueva', en: '...' },
basePath : '/renders/', // basePath raíz — heredado por objetos y vistas
// Pool de opciones — todas las opciones disponibles en el catálogo
options: { ...OPTIONS },
// Objetos configurables
objects: {
'ob:apartamento': APARTAMENTO,
'ob:duplex' : DUPLEX,
},
// Reglas de validación — aplican a todos los objetos del catálogo
rules: { ...RULES },
// Opcional
tax: { rate: 0.21, included: false, label: { es: 'IVA', en: 'VAT' } },
};
```
---
## Uso con ConfigurationEngine
### Inicialización
```typescript
import { ConfigurationEngine } from '@/libs/vice';
const engine = new ConfigurationEngine(CATALOGO_VIVIENDAS, logr);
// Activar un objeto — crea su SelectionState con los defaultValues
engine.setActiveObject('ob:apartamento');
```
### Seleccionar opciones
```typescript
// Selección simple
engine.select('at:suelo', 'op:suelo_parquet');
// Selección con cantidad (opción cuantificable)
engine.select('at:armario_puertas', 'op:armario_lacado', 3);
// El resultado indica si fue aceptada y qué cambió
const result = engine.select('at:revest_bano', 'op:revest_marmol');
// Con calidad estándar:
// { success: false, reason: 'El mármol no está disponible en calidad estándar' }
```
### Renderizar opciones disponibles
```typescript
const attrState = engine.getAttributeState('at:revest_bano');
attrState.options.forEach(({ optionId, option, available, reason, quantityConfig }) => {
// available: false → deshabilitar en UI
// reason: string → mostrar en tooltip
// quantityConfig → mostrar selector de cantidad si está presente
});
```
### Resolver la imagen de una sección
```typescript
const { url, isFallback } = engine.resolveView('sc:salon', 'vw:front');
// url: '/renders/vivienda/apartamento/salon/front/parquet_blanco.jpg'
```
### Obtener el precio
```typescript
const { subtotal, taxAmount, total, breakdown } = engine.getPrice();
// subtotal: 4920
// breakdown: [{ attrId, optionId, amount, isDynamic }, ...]
```
### Reactividad
```typescript
// Suscribirse a cambios
const unsubscribe = engine.onChange((event) => {
if (event.affectsVisual) {
// actualizar imagen de la sección afectada
const { url } = engine.resolveView(sectionId, viewId);
updateImage(url);
}
if (event.affectsPrice) {
// actualizar precio mostrado
updatePrice(engine.getPrice());
}
});
// Limpiar al destruir el componente
onDestroy(unsubscribe);
```
### Multi-objeto — configuraciones paralelas
El engine mantiene un estado independiente por objeto. El usuario puede configurar varios modelos sin perder datos:
```typescript
// Configurar apartamento
engine.setActiveObject('ob:apartamento');
engine.select('at:suelo', 'op:suelo_parquet');
// Cambiar a dúplex — el estado del apartamento se preserva
engine.setActiveObject('ob:duplex');
engine.select('at:suelo', 'op:suelo_porcelanico');
// Volver al apartamento — recupera su estado anterior
engine.setActiveObject('ob:apartamento');
engine.getValue('at:suelo'); // → { optionId: 'op:suelo_parquet' }
```
---
## Referencia rápida de tipos
```typescript
// ID con prefijo tipado
type OptionID = `op:${string}`
type AttrID = `at:${string}`
type SectionID = `sc:${string}`
type ViewID = `vw:${string}`
type ObjectID = `ob:${string}`
type RuleID = `rl:${string}`
// Estado de selección
type OptionSelection = { optionId: OptionID; quantity?: number }
type SelectionMap = Record<AttrID, OptionSelection | Value>
// Atributos
type Attribute = FixedAttribute | DynamicAttribute | ComputedAttribute
// Estrategias de vista
type VisualStrategy = 'static_image' | 'composite_layers' | 'api_generated' | 'three_d'
// Acciones de regla
type RuleActionType = 'forbid' | 'allow' | 'require' | 'suggest'
// Severidad
type Severity = 'error' | 'warning' | 'info'
```

@ -0,0 +1,263 @@
/**
* ============================================================================
* CONFIGURATION ENGINE
* ============================================================================
*
* Orquestador principal — única API que consume la UI.
* Gestiona múltiples objetos en paralelo con un objeto activo.
*
* Uso:
* const engine = new ConfigurationEngine(catalog, logr);
* engine.setActiveObject('ob:apartamento');
* engine.select('at:suelo', 'op:parquet');
* engine.getPrice();
*/
import type { Logr } from '@/libs/logr';
import type {
ConfigurationCatalog,
ObjectID,
OptionID,
AttrID,
OptionSelection,
SelectionMap,
SectionID,
ViewID
} from '../types';
import type { AttributeState } from './selection-state';
import type { PricingResult } from './pricing';
import type { ResolveResult, ApiResolveResult } from './template-resolver';
import { SelectionState } from './selection-state';
import { PricingEngine } from './pricing';
import { TemplateResolver } from './template-resolver';
import { ENGINE_CATEGORIES, SELECTION_ERRORS } from '../consts/messages';
// ============================================================================
// TYPES
// ============================================================================
export type ChangeEventType = 'selection' | 'object';
export interface ChangeEvent {
type : ChangeEventType;
objectId: ObjectID;
/** Solo presente cuando type === 'selection' */
attrId? : AttrID;
affectsVisual: boolean;
affectsPrice : boolean;
}
export type ChangeListener = (event: ChangeEvent) => void;
export interface SelectionOutcome {
success : boolean;
reason? : string;
affectsVisual: boolean;
affectsPrice : boolean;
}
// ============================================================================
// CONFIGURATION ENGINE
// ============================================================================
export class ConfigurationEngine {
private readonly catalog : ConfigurationCatalog;
private readonly logr : Logr;
private readonly pricingEngine : PricingEngine;
private readonly templateResolver: TemplateResolver;
/** SelectionState por objectId */
private readonly states : Map<ObjectID, SelectionState> = new Map();
private activeObjectId : ObjectID | null = null;
private readonly listeners : Set<ChangeListener> = new Set();
constructor(catalog: ConfigurationCatalog, logr: Logr) {
this.catalog = catalog;
this.logr = logr;
this.pricingEngine = new PricingEngine(logr);
this.templateResolver = new TemplateResolver(logr);
}
// -------------------------------------------------------------------------
// Objeto activo
// -------------------------------------------------------------------------
/**
* Activa un objeto del catálogo.
* Crea su SelectionState si no existe todavía.
* Emite evento 'object'.
*/
setActiveObject(objectId: ObjectID): void {
if (!this.catalog.objects[objectId]) {
this.logr.error(
ENGINE_CATEGORIES.SELECTION,
SELECTION_ERRORS.OBJECT_NOT_FOUND(objectId),
{ objectId }
);
return;
}
if (!this.states.has(objectId)) {
this.states.set(
objectId,
new SelectionState(objectId, this.catalog, this.logr)
);
}
this.activeObjectId = objectId;
this.emit({ type: 'object', objectId, affectsVisual: true, affectsPrice: true });
}
getActiveObjectId(): ObjectID | null {
return this.activeObjectId;
}
// -------------------------------------------------------------------------
// Selección
// -------------------------------------------------------------------------
/**
* Selecciona una opción para un atributo del objeto activo.
* Solo acepta valores permitidos por el RuleEngine — modelo preventivo.
* Emite evento 'selection' si tiene éxito.
*/
select(attrId: AttrID, optionId: OptionID, quantity?: number): SelectionOutcome {
const state = this.activeState();
if (!state) return { success: false, reason: 'No active object', affectsVisual: false, affectsPrice: false };
const value: OptionSelection = { optionId, quantity };
const result = state.select(attrId, value);
if (!result.success) {
return { ...result, affectsVisual: false, affectsPrice: false };
}
const { affectsVisual, affectsPrice } = this.getAttrDisplayFlags(attrId);
this.emit({
type : 'selection',
objectId: this.activeObjectId!,
attrId,
affectsVisual,
affectsPrice,
});
return { success: true, affectsVisual, affectsPrice };
}
getValue(attrId: AttrID): OptionSelection | unknown {
return this.activeState()?.getValue(attrId);
}
getSelection(): SelectionMap {
return this.activeState()?.getSelection() ?? {};
}
// -------------------------------------------------------------------------
// Estado calculado
// -------------------------------------------------------------------------
getAttributeState(attrId: AttrID): AttributeState | null {
return this.activeState()?.getAttributeState(attrId) ?? null;
}
getAllAttributeStates(): Map<AttrID, AttributeState> {
return this.activeState()?.getAllAttributeStates() ?? new Map();
}
// -------------------------------------------------------------------------
// Precio
// -------------------------------------------------------------------------
getPrice(): PricingResult | null {
if (!this.activeObjectId) return null;
return this.pricingEngine.calculate(
this.activeObjectId,
this.getSelection(),
this.catalog
);
}
// -------------------------------------------------------------------------
// Visual
// -------------------------------------------------------------------------
resolveView(sectionId: SectionID, viewId: ViewID): ResolveResult {
const objectId = this.activeObjectId;
if (!objectId) return { url: '', isFallback: true };
return this.templateResolver.resolve(
{ objectId, sectionId, viewId },
this.getSelection(),
this.catalog
);
}
resolveViewApi(sectionId: SectionID, viewId: ViewID): ApiResolveResult | null {
const objectId = this.activeObjectId;
if (!objectId) return null;
return this.templateResolver.resolveApi(
{ objectId, sectionId, viewId },
this.getSelection(),
this.catalog
);
}
// -------------------------------------------------------------------------
// Validación
// -------------------------------------------------------------------------
isValid(): boolean {
return this.activeState()?.isValid() ?? true;
}
// -------------------------------------------------------------------------
// Eventos
// -------------------------------------------------------------------------
onChange(listener: ChangeListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private activeState(): SelectionState | null {
if (!this.activeObjectId) return null;
return this.states.get(this.activeObjectId) ?? null;
}
private emit(event: ChangeEvent): void {
for (const listener of this.listeners) {
try { listener(event); } catch { /* listener errors no deben romper el engine */ }
}
}
private getAttrDisplayFlags(attrId: AttrID): { affectsVisual: boolean; affectsPrice: boolean } {
if (!this.activeObjectId) return { affectsVisual: false, affectsPrice: false };
const obj = this.catalog.objects[this.activeObjectId];
if (!obj) return { affectsVisual: false, affectsPrice: false };
// Buscar el atributo en global y en secciones
const attrs = [
...obj.attributes,
...Object.values(obj.sections).flatMap(s => s.attributes ?? [])
];
const attr = attrs.find(a => a.id === attrId);
if (!attr) return { affectsVisual: false, affectsPrice: false };
const display = attr.display;
return {
affectsVisual: Boolean(display.affectsVisual),
affectsPrice : Boolean(display.affectsPrice),
};
}
}

@ -5,7 +5,7 @@
*/ */
import type { Logr } from '@/libs/logr'; import type { Logr } from '@/libs/logr';
import type { JsonLogic } from '../types'; import type { JsonLogic } from '../types';
import { ENGINE_CATEGORIES, JSON_LOGIC_WARNINGS } from '../consts/messages'; import { ENGINE_CATEGORIES, JSON_LOGIC_WARNINGS } from '../consts';
/** /**
* Evaluador de expresiones JsonLogic. * Evaluador de expresiones JsonLogic.

@ -0,0 +1,8 @@
export * from './configuration.ts';
export * from './evaluator.ts';
export * from './pricing.ts';
export * from './rule.ts';
export * from './selection-state.ts';
export * from './template-resolver.ts';

@ -13,19 +13,19 @@
* El tax se aplica al total, no por opción. * El tax se aplica al total, no por opción.
*/ */
import type { Logr } from '@/libs/logr';
import type { import type {
ConfigurationCatalog, ConfigurationCatalog,
ConfigurableObject, ConfigurableObject,
Attribute, Attribute,
QuantifiableAttribute, DynamicAttribute,
TaxInfo, ObjectID, TaxInfo,
ObjectID,
SelectionMap,
} from '../types'; } from '../types';
import type { Logr } from '@/libs/logr'; import { isOptionSelection } from '../guards';
import type { SelectionMap } from './rule';
import { JsonLogicEvaluator } from './evaluator'; import { JsonLogicEvaluator } from './evaluator';
import { ENGINE_CATEGORIES, PRICING_ERRORS } from '../consts/messages'; import { ENGINE_CATEGORIES, PRICING_ERRORS } from '../consts';
// ============================================================================ // ============================================================================
// TYPES // TYPES
@ -112,10 +112,11 @@ export class PricingEngine {
for (const attr of allAttrs) { for (const attr of allAttrs) {
if (!attr.display.affectsPrice) continue; if (!attr.display.affectsPrice) continue;
const selectedValue = state[attr.id]; const rawValue = state[attr.id];
if (!selectedValue) continue; if (!rawValue) continue;
const optionId = String(selectedValue); const selection = isOptionSelection(rawValue) ? rawValue : null;
const optionId = selection ? selection.optionId : String(rawValue);
const optionDef = catalog.options[optionId as keyof typeof catalog.options]; const optionDef = catalog.options[optionId as keyof typeof catalog.options];
if (!optionDef) { if (!optionDef) {
@ -138,7 +139,17 @@ export class PricingEngine {
continue; continue;
} }
const amount = this.resolveAmount(attr, pricing.baseAmount, pricing.dynamicExpression, state); // Quantity: from OptionSelection, or from AttributeOption.quantity.default
const attrOption = attr.type === 'dynamic'
? (attr as DynamicAttribute).options.find(o => o.optionId === optionId)
: undefined;
const quantity = selection?.quantity
?? attrOption?.quantity?.default;
// dynamicExpression override: AttributeOption takes precedence over OptionDefinition
const dynamicExpr = attrOption?.dynamicExpression ?? pricing.dynamicExpression;
const amount = this.resolveAmount(attr, pricing.baseAmount, dynamicExpr, quantity, state);
subtotal += amount; subtotal += amount;
if (amount !== 0) { if (amount !== 0) {
@ -167,12 +178,12 @@ export class PricingEngine {
attr : Attribute, attr : Attribute,
baseAmount : number, baseAmount : number,
dynamicExpression: unknown, dynamicExpression: unknown,
quantity : number | undefined,
state : SelectionMap, state : SelectionMap,
): number { ): number {
if (!dynamicExpression) return baseAmount; if (!dynamicExpression) return baseAmount;
try { try {
const quantity = this.isQuantifiable(attr) ? attr.quantity : undefined;
const data = { const data = {
quantity, quantity,
baseAmount, baseAmount,
@ -214,10 +225,6 @@ export class PricingEngine {
return attrs; return attrs;
} }
private isQuantifiable(attr: Attribute): attr is QuantifiableAttribute {
return attr.type === 'quantifiable';
}
private normalizeStateKeys(state: SelectionMap): Record<string, unknown> { private normalizeStateKeys(state: SelectionMap): Record<string, unknown> {
const normalized: Record<string, unknown> = {}; const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(state)) { for (const [key, value] of Object.entries(state)) {

@ -9,16 +9,18 @@
*/ */
import type { Logr } from '@/libs/logr'; import type { Logr } from '@/libs/logr';
import type { import {
ValidationRule, type ValidationRule,
ValidationRuleAction, type ValidationRuleAction,
AttrID, type AttrID,
OptionID, type OptionID,
Value, type Value,
Severity type Severity,
type OptionSelection
} from '../types'; } from '../types';
import { isOptionSelection } from '../guards';
import { JsonLogicEvaluator } from './evaluator'; import { JsonLogicEvaluator } from './evaluator';
import { ENGINE_CATEGORIES, RULE_ENGINE_ERRORS } from '../consts/messages'; import { ENGINE_CATEGORIES, RULE_ENGINE_ERRORS } from '../consts';
// ============================================================================ // ============================================================================
@ -27,9 +29,9 @@ import { ENGINE_CATEGORIES, RULE_ENGINE_ERRORS } from '../consts/messages';
/** /**
* Estado de selección actual del usuario. * Estado de selección actual del usuario.
* Mapa de AttrID → valor seleccionado. * Mapa de AttrID → OptionSelection (DynamicAttribute) o Value (FixedAttribute).
*/ */
export type SelectionMap = Record<AttrID, Value>; export type SelectionMap = Record<AttrID, OptionSelection | Value>;
/** /**
* Resultado de evaluar una regla contra el estado actual. * Resultado de evaluar una regla contra el estado actual.
@ -67,7 +69,7 @@ export interface RuleViolation {
// RULE ENGINE // RULE ENGINE
// ============================================================================ // ============================================================================
export class Rule { export class RuleEngine {
private readonly evaluator: JsonLogicEvaluator; private readonly evaluator: JsonLogicEvaluator;
private readonly logr : Logr; private readonly logr : Logr;
@ -166,16 +168,21 @@ export class Rule {
...(action.values as OptionID[]) ...(action.values as OptionID[])
]; ];
// Violación si el valor actual está prohibido // Violación si el valor actual está prohibido
if (action.values.includes(state[attrId])) { if (action.values.includes(getOptionId(state[attrId]) as OptionID)) {
result.violations.push({ rule, severity: rule.severity }); result.violations.push({ rule, severity: rule.severity });
} }
break; break;
case 'require': case 'require':
result.required = true; result.required = true;
// Violación si no hay valor seleccionado // Violación si no hay valor seleccionado, o si hay valores
if (!state[attrId]) { // requeridos específicos y el actual no está entre ellos
result.violations.push({ rule, severity: rule.severity }); {
const current = getOptionId(state[attrId]);
const violated = action.values?.length
? !current || !action.values.includes(current as OptionID)
: !current;
if (violated) result.violations.push({ rule, severity: rule.severity });
} }
break; break;
@ -206,9 +213,9 @@ export class Rule {
if (!result) return true; if (!result) return true;
if (result.forbiddenValues?.includes(value as OptionID)) return false; if (result.forbiddenValues?.includes(value as OptionID)) return false;
if (result.allowedValues && !result.allowedValues.includes(value as OptionID)) return false; return !(result.allowedValues && !result.allowedValues.includes(value as OptionID));
return true;
} }
/** /**
@ -273,12 +280,27 @@ export class Rule {
* Normaliza las claves del estado para JsonLogic. * Normaliza las claves del estado para JsonLogic.
* 'at:calidad' → 'at_calidad' (los ':' no son válidos como nombres de variable) * 'at:calidad' → 'at_calidad' (los ':' no son válidos como nombres de variable)
*/ */
function normalizeKeys(state: SelectionMap): Record<string, Value> { /**
const normalized: Record<string, Value> = {}; * Extrae el OptionID de un valor del estado — maneja tanto OptionSelection como Value.
*/
function getOptionId(value: OptionSelection | Value | undefined): OptionID | undefined {
if (value === undefined || value === null) return undefined;
if (isOptionSelection(value)) return value.optionId as OptionID;
return value as OptionID;
}
function normalizeKeys(state: SelectionMap): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(state)) { for (const [key, value] of Object.entries(state)) {
normalized[key.replace(':', '_')] = value; const normalized = key.replace(':', '_');
if (isOptionSelection(value)) {
result[normalized] = value.optionId;
if (value.quantity !== undefined) result[normalized + '_quantity'] = value.quantity;
} else {
result[normalized] = value;
}
} }
return normalized; return result;
} }
function severityOrder(severity: Severity): number { function severityOrder(severity: Severity): number {
@ -295,4 +317,4 @@ function severityOrder(severity: Severity): number {
// ============================================================================ // ============================================================================
// El singleton se crea en el wiring del engine, no aquí. // El singleton se crea en el wiring del engine, no aquí.
// Ejemplo: export const ruleEngine = new Rule(logr); // Ejemplo: export const ruleEngine = new RuleEngine(logr);

@ -10,20 +10,25 @@
* El usuario nunca puede seleccionar un valor prohibido. * El usuario nunca puede seleccionar un valor prohibido.
*/ */
import type {Logr} from '@/libs/logr';
import type { import type {
ConfigurationCatalog,
ConfigurableObject,
Attribute, Attribute,
AttrID,
ConfigurableObject,
ConfigurationCatalog,
DynamicAttribute, DynamicAttribute,
OptionDefinition,
ObjectID, ObjectID,
OptionDefinition,
OptionID,
OptionSelection,
QuantityConfig,
Value
} from '../types'; } from '../types';
import type { AttrID, OptionID } from '../types'; import type {SelectionMap} from './rule';
import type { Value } from '../types';
import type { Logr } from '@/libs/logr'; import { isOptionSelection } from '../guards';
import { Rule } from './rule.ts'; import {RuleEngine} from './rule';
import type { SelectionMap } from './rule.ts'; import {ENGINE_CATEGORIES, SELECTION_ERRORS} from '../consts';
import { ENGINE_CATEGORIES, SELECTION_ERRORS } from '../consts/messages.ts';
// ============================================================================ // ============================================================================
// TYPES // TYPES
@ -36,6 +41,8 @@ export interface AvailableOption {
optionId : OptionID; optionId : OptionID;
option : OptionDefinition; option : OptionDefinition;
available : boolean; available : boolean;
/** Si la opción es cuantificable — configuración de cantidad */
quantityConfig?: QuantityConfig;
/** Razón por la que no está disponible — mensaje de la regla que la prohíbe */ /** Razón por la que no está disponible — mensaje de la regla que la prohíbe */
reason? : string; reason? : string;
} }
@ -45,7 +52,7 @@ export interface AvailableOption {
*/ */
export interface AttributeState { export interface AttributeState {
attr : Attribute; attr : Attribute;
value : Value; value : OptionSelection | Value;
options : AvailableOption[]; // solo para DynamicAttribute options : AvailableOption[]; // solo para DynamicAttribute
required: boolean; required: boolean;
} }
@ -63,7 +70,7 @@ export type SelectionResult =
export class SelectionState { export class SelectionState {
private readonly logr : Logr; private readonly logr : Logr;
private readonly ruleEngine : Rule; private readonly ruleEngine : RuleEngine;
private readonly catalog : ConfigurationCatalog; private readonly catalog : ConfigurationCatalog;
private readonly object : ConfigurableObject; private readonly object : ConfigurableObject;
private selection : SelectionMap; private selection : SelectionMap;
@ -74,18 +81,15 @@ export class SelectionState {
logr : Logr, logr : Logr,
) { ) {
this.logr = logr; this.logr = logr;
this.ruleEngine = new Rule(logr); this.ruleEngine = new RuleEngine(logr);
this.catalog = catalog; this.catalog = catalog;
this.selection = {}; this.selection = {};
const object = catalog.objects[objectId]; const object = catalog.objects[objectId];
if (!object) { if (!object) {
this.logr.error( const msg = SELECTION_ERRORS.OBJECT_NOT_FOUND(objectId);
ENGINE_CATEGORIES.SELECTION, this.logr.error(ENGINE_CATEGORIES.SELECTION, msg, { objectId });
SELECTION_ERRORS.OBJECT_NOT_FOUND(objectId), throw new Error(msg);
{ objectId }
);
throw new Error(`Object "${objectId}" not found in catalog`);
} }
this.object = object; this.object = object;
@ -101,11 +105,12 @@ export class SelectionState {
* Solo acepta valores que el RuleEngine permite dado el estado actual. * Solo acepta valores que el RuleEngine permite dado el estado actual.
* Modelo preventivo — el estado siempre es válido tras la selección. * Modelo preventivo — el estado siempre es válido tras la selección.
*/ */
select(attrId: AttrID, value: Value): SelectionResult { select(attrId: AttrID, value: OptionSelection | Value): SelectionResult {
const rules = Object.values(this.catalog.rules ?? {}); const rules = Object.values(this.catalog.rules ?? {});
if (!this.ruleEngine.isValueAllowed(attrId, value, rules, this.selection)) { const optionId = isOptionSelection(value) ? value.optionId : value;
const reason = this.getProhibitedReason(attrId, value); if (!this.ruleEngine.isValueAllowed(attrId, optionId, rules, this.selection)) {
const reason = this.getProhibitedReason(attrId, optionId);
this.logr.warn( this.logr.warn(
ENGINE_CATEGORIES.SELECTION, ENGINE_CATEGORIES.SELECTION,
SELECTION_ERRORS.INVALID_OPTION(attrId, String(value) as OptionID), SELECTION_ERRORS.INVALID_OPTION(attrId, String(value) as OptionID),
@ -121,7 +126,7 @@ export class SelectionState {
/** /**
* Devuelve el valor actual de un atributo. * Devuelve el valor actual de un atributo.
*/ */
getValue(attrId: AttrID): Value { getValue(attrId: AttrID): OptionSelection | Value {
return this.selection[attrId]; return this.selection[attrId];
} }
@ -187,10 +192,16 @@ export class SelectionState {
const selection: SelectionMap = {}; const selection: SelectionMap = {};
for (const attr of this.collectAttrs()) { for (const attr of this.collectAttrs()) {
if (attr.type === 'dynamic' || attr.type === 'quantifiable') { if (attr.type === 'dynamic') {
const dynamic = attr as DynamicAttribute; const dynamic = attr as DynamicAttribute;
if (dynamic.defaultValue !== undefined) { if (dynamic.defaultValue !== undefined) {
selection[attr.id] = dynamic.defaultValue; const defaultOption = dynamic.options.find(o => o.optionId === dynamic.defaultValue);
selection[attr.id] = {
optionId: dynamic.defaultValue,
quantity: defaultOption?.quantity
? (dynamic.defaultQuantity ?? defaultOption.quantity.default)
: undefined,
};
} }
} }
} }
@ -210,12 +221,13 @@ export class SelectionState {
attr : DynamicAttribute, attr : DynamicAttribute,
rules: any[] rules: any[]
): AvailableOption[] { ): AvailableOption[] {
return attr.options.map(({ optionId }) => { return attr.options.map((attrOption) => {
const { optionId } = attrOption;
const option = this.catalog.options[optionId]; const option = this.catalog.options[optionId];
const available = this.ruleEngine.isValueAllowed(attr.id, optionId, rules, this.selection); const available = this.ruleEngine.isValueAllowed(attr.id, optionId, rules, this.selection);
const reason = available ? undefined : this.getProhibitedReason(attr.id, optionId); const reason = available ? undefined : this.getProhibitedReason(attr.id, optionId);
return { optionId, option, available, reason }; return { optionId, option, available, reason, quantityConfig: attrOption.quantity };
}); });
} }

@ -3,9 +3,20 @@
* TEMPLATE RESOLVER * TEMPLATE RESOLVER
* ============================================================================ * ============================================================================
* *
* Resuelve templates de imagen a URLs completas combinando: * Resuelve templates de imagen a URLs completas (static_image, composite_layers)
* - basePath heredado en cascada (vista → sección → objeto → catálogo) * o construye los parámetros para llamadas API (api_generated).
* - template con placeholders {at:<code>} resueltos al code de la opción activa *
* Sistema de placeholders unificado para todas las estrategias:
*
* Contexto:
* {object} → code del ConfigurableObject
* {section} → code de la VisualSection
* {view} → code de la SectionView
*
* Atributos:
* {at:<code>} → code de la OptionDefinition activa para ese atributo
*
* basePath hereda en cascada: vista → sección → objeto → catálogo
*/ */
import type { Logr } from '@/libs/logr'; import type { Logr } from '@/libs/logr';
@ -20,8 +31,9 @@ import type {
ViewID, ViewID,
CompositeLayersConfig, CompositeLayersConfig,
ApiGeneratedConfig, ApiGeneratedConfig,
OptionID,
} from '../types'; } from '../types';
import { isOptionID } from '../types'; import {isOptionID, isOptionSelection} from '../guards';
import { import {
ENGINE_CATEGORIES, ENGINE_CATEGORIES,
@ -30,7 +42,6 @@ import {
} from '../consts/messages'; } from '../consts/messages';
import type { SelectionMap } from './rule.ts'; import type { SelectionMap } from './rule.ts';
// ============================================================================ // ============================================================================
// TYPES // TYPES
// ============================================================================ // ============================================================================
@ -344,7 +355,8 @@ export class TemplateResolver {
return null; return null;
} }
const selectedValue = state[attr.id]; const rawValue = state[attr.id];
const selectedValue = isOptionSelection(rawValue) ? rawValue.optionId : rawValue;
if (!selectedValue) { if (!selectedValue) {
this.logr.error( this.logr.error(
@ -364,7 +376,7 @@ export class TemplateResolver {
return null; return null;
} }
return catalog.options[selectedValue].code; return catalog.options[selectedValue as OptionID].code;
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

@ -15,12 +15,11 @@ import type {
VisualSection, VisualSection,
DynamicAttribute, DynamicAttribute,
FixedAttribute, FixedAttribute,
SectionView, QuantifiableAttribute, QuantifiableOptionAttribute, SectionView,
} from '../types'; } from '../types';
import type { AttrID, SectionID, OptionID, ObjectID, RuleID, ViewID } from '../types'; import type { AttrID, SectionID, OptionID, ObjectID, RuleID, ViewID } from '../types';
// ============================================================================ // ============================================================================
// IDs — OPCIONES // IDs — OPCIONES
// ============================================================================ // ============================================================================
@ -587,20 +586,36 @@ const seccionArmario = (): VisualSection => ({
id : ATTR_ARMARIO_PUERTAS, id : ATTR_ARMARIO_PUERTAS,
code : 'armario_puertas', code : 'armario_puertas',
name : { es: 'Puertas de armario', en: 'Wardrobe doors' }, name : { es: 'Puertas de armario', en: 'Wardrobe doors' },
description : { es: 'Número y material de las puertas del armario', en: 'Number and material of wardrobe doors' }, description : { es: 'Material y número de puertas del armario', en: 'Wardrobe door material and count' },
type : 'quantifiable', type : 'dynamic',
dataType : 'reference', dataType : 'reference',
defaultValue: OPT_ARMARIO_LACADO, defaultValue : OPT_ARMARIO_LACADO,
quantity : 2, defaultQuantity : 2,
minQuantity : 2, userConfigurableQuantity: true,
maxQuantity : 3,
unit : { es: 'puertas', en: 'doors' },
options : [ options : [
{ optionId: OPT_ARMARIO_LACADO, priority: 1 }, {
{ optionId: OPT_ARMARIO_MADERA, priority: 2 }, optionId: OPT_ARMARIO_LACADO,
priority: 1,
// 2 puertas → 350€/u, 3 puertas → 300€/u (descuento volumen)
quantity: { default: 2, min: 2, max: 3, step: 1, unit: { es: 'puertas', en: 'doors' } },
dynamicExpression: {
'if': [
{ '>=': [{ var: 'quantity' }, 3] },
{ '*': [{ var: 'quantity' }, 300] },
{ '*': [{ var: 'quantity' }, 350] }
]
}
},
{
optionId: OPT_ARMARIO_MADERA,
priority: 2,
// Precio fijo por puerta sin descuento
quantity: { default: 2, min: 2, max: 3, step: 1, unit: { es: 'puertas', en: 'doors' } },
dynamicExpression: { '*': [{ var: 'quantity' }, 500] }
},
], ],
display : { uiVisible: true, affectsVisual: true, affectsPrice: true } display : { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as QuantifiableOptionAttribute, } as DynamicAttribute,
{ {
id : ATTR_ARMARIO_MATERIAL, id : ATTR_ARMARIO_MATERIAL,
code : 'armario_material', code : 'armario_material',

@ -0,0 +1,3 @@
export * from './housing-catalog.ts';

@ -0,0 +1,16 @@
// ============================================================================
// TYPE GUARDS
// ============================================================================
import type {AttrID, OptionID} from "@/libs/vice/types";
export function isOptionID(value: unknown): value is OptionID {
return typeof value === 'string' && value.startsWith('op:');
}
export function isAttrID(value: unknown): value is AttrID {
return typeof value === 'string' && value.startsWith('at:');
}

@ -0,0 +1,5 @@
export * from './ids.ts';
export * from './option.ts';

@ -0,0 +1,14 @@
import type {AttrID, OptionID, OptionSelection, Value} from "../types";
/**
* Helper para narrowear el valor de un atributo dinámico.
*/
export function isOptionSelection(value: OptionSelection | Value): value is OptionSelection {
return (
typeof value === 'object' &&
value !== null &&
'optionId' in value
);
}

@ -0,0 +1,361 @@
/**
* ============================================================================
* CONFIGURATION ENGINE — TESTS
* ============================================================================
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ConfigurationEngine } from '../engines/configuration';
import type { Logr } from '@/libs/logr';
import {
CATALOGO_VIVIENDAS,
IDS_TEST,
} from '../fixtures/housing-catalog.ts';
const {
OBJ_APARTAMENTO,
OBJ_DUPLEX,
ATTR_CALIDAD,
ATTR_SUELO,
ATTR_REVEST_BANO,
ATTR_SANITARIO,
ATTR_ARMARIO_PUERTAS,
OPT_CALIDAD_ESTANDAR,
OPT_CALIDAD_PREMIUM,
OPT_CALIDAD_LUJO,
OPT_SUELO_PARQUET,
OPT_SUELO_CERAMICA,
OPT_REVEST_CERAMICA,
OPT_REVEST_MARMOL,
OPT_SANIT_NEGRO,
OPT_ARMARIO_LACADO,
OPT_ARMARIO_MADERA,
SEC_SALON,
VIEW_FRONT,
} = IDS_TEST;
// ============================================================================
// SETUP
// ============================================================================
function makeLogr(): Logr {
return {
debug : vi.fn(),
info : vi.fn(),
warn : vi.fn(),
error : vi.fn(),
getLogs : vi.fn(),
clear : vi.fn(),
serialize: vi.fn(),
setLevel : vi.fn(),
setMaxLogs: vi.fn(),
} as unknown as Logr;
}
function makeEngine() {
return new ConfigurationEngine(CATALOGO_VIVIENDAS, makeLogr());
}
let engine: ConfigurationEngine;
beforeEach(() => {
engine = makeEngine();
engine.setActiveObject(OBJ_APARTAMENTO);
});
// ============================================================================
// setActiveObject
// ============================================================================
describe('setActiveObject', () => {
it('establece el objeto activo', () => {
expect(engine.getActiveObjectId()).toBe(OBJ_APARTAMENTO);
});
it('objeto inexistente no cambia el activo', () => {
engine.setActiveObject('ob:noexiste' as any);
expect(engine.getActiveObjectId()).toBe(OBJ_APARTAMENTO);
});
it('cambia el objeto activo', () => {
engine.setActiveObject(OBJ_DUPLEX);
expect(engine.getActiveObjectId()).toBe(OBJ_DUPLEX);
});
it('emite evento object al cambiar', () => {
const listener = vi.fn();
engine.onChange(listener);
engine.setActiveObject(OBJ_DUPLEX);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({ type: 'object', objectId: OBJ_DUPLEX })
);
});
it('preserva el estado al volver al objeto anterior', () => {
engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
engine.setActiveObject(OBJ_DUPLEX);
engine.setActiveObject(OBJ_APARTAMENTO);
const val = engine.getValue(ATTR_SUELO) as any;
expect(val.optionId).toBe(OPT_SUELO_PARQUET);
});
});
// ============================================================================
// select
// ============================================================================
describe('select', () => {
it('acepta una selección válida', () => {
const result = engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
expect(result.success).toBe(true);
});
it('rechaza una selección prohibida', () => {
const result = engine.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL);
expect(result.success).toBe(false);
});
it('devuelve reason al rechazar', () => {
const result = engine.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL);
expect(result.success).toBe(false);
if (!result.success) expect(result.reason).toBeTruthy();
});
it('acepta selección con cantidad', () => {
const result = engine.select(ATTR_ARMARIO_PUERTAS, OPT_ARMARIO_LACADO, 3);
expect(result.success).toBe(true);
const val = engine.getValue(ATTR_ARMARIO_PUERTAS) as any;
expect(val.quantity).toBe(3);
});
it('affectsVisual true para atributo visual', () => {
const result = engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
expect(result.affectsVisual).toBe(true);
});
it('affectsPrice true para atributo con precio', () => {
const result = engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
expect(result.affectsPrice).toBe(true);
});
it('falla si no hay objeto activo', () => {
const eng = makeEngine(); // sin setActiveObject
const result = eng.select(ATTR_SUELO, OPT_SUELO_PARQUET);
expect(result.success).toBe(false);
});
it('emite evento selection al seleccionar', () => {
const listener = vi.fn();
engine.onChange(listener);
engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
type : 'selection',
objectId: OBJ_APARTAMENTO,
attrId : ATTR_SUELO,
})
);
});
it('no emite evento si la selección es rechazada', () => {
const listener = vi.fn();
engine.onChange(listener);
engine.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL);
expect(listener).not.toHaveBeenCalled();
});
});
// ============================================================================
// getSelection / getValue
// ============================================================================
describe('getSelection / getValue', () => {
it('getSelection devuelve estado del objeto activo', () => {
const sel = engine.getSelection();
expect(sel[ATTR_CALIDAD]).toBeDefined();
});
it('getSelection devuelve objeto vacío sin objeto activo', () => {
const eng = makeEngine();
expect(eng.getSelection()).toEqual({});
});
it('getValue devuelve OptionSelection tras selección', () => {
engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
const val = engine.getValue(ATTR_SUELO) as any;
expect(val.optionId).toBe(OPT_SUELO_PARQUET);
});
it('estados de objetos distintos son independientes', () => {
engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
engine.setActiveObject(OBJ_DUPLEX);
const val = engine.getValue(ATTR_SUELO) as any;
// Dúplex tiene su propio estado — no el del apartamento
expect(val?.optionId).not.toBe(OPT_SUELO_PARQUET);
});
});
// ============================================================================
// getAttributeState / getAllAttributeStates
// ============================================================================
describe('getAttributeState', () => {
it('devuelve opciones disponibles para un atributo', () => {
const s = engine.getAttributeState(ATTR_SUELO);
expect(s?.options.length).toBeGreaterThan(0);
});
it('devuelve null sin objeto activo', () => {
const eng = makeEngine();
expect(eng.getAttributeState(ATTR_SUELO)).toBeNull();
});
it('mármol no disponible con calidad estándar', () => {
const s = engine.getAttributeState(ATTR_REVEST_BANO);
const marmol = s?.options.find(o => o.optionId === OPT_REVEST_MARMOL);
expect(marmol?.available).toBe(false);
});
it('mármol disponible tras cambiar a premium', () => {
engine.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM);
const s = engine.getAttributeState(ATTR_REVEST_BANO);
const marmol = s?.options.find(o => o.optionId === OPT_REVEST_MARMOL);
expect(marmol?.available).toBe(true);
});
});
describe('getAllAttributeStates', () => {
it('devuelve todos los atributos del objeto activo', () => {
const states = engine.getAllAttributeStates();
expect(states.size).toBeGreaterThan(0);
expect(states.has(ATTR_CALIDAD)).toBe(true);
expect(states.has(ATTR_SUELO)).toBe(true);
});
it('devuelve mapa vacío sin objeto activo', () => {
const eng = makeEngine();
expect(eng.getAllAttributeStates().size).toBe(0);
});
});
// ============================================================================
// getPrice
// ============================================================================
describe('getPrice', () => {
it('devuelve un PricingResult', () => {
const price = engine.getPrice();
expect(price).not.toBeNull();
expect(typeof price?.subtotal).toBe('number');
expect(typeof price?.total).toBe('number');
});
it('el precio cambia al cambiar una opción con precio', () => {
const before = engine.getPrice()?.subtotal ?? 0;
engine.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM);
const after = engine.getPrice()?.subtotal ?? 0;
expect(after).toBeGreaterThan(before);
});
it('devuelve null sin objeto activo', () => {
const eng = makeEngine();
expect(eng.getPrice()).toBeNull();
});
it('precio con armario 3 puertas lacadas = base + 900', () => {
const base = engine.getPrice()!.subtotal;
engine.select(ATTR_ARMARIO_PUERTAS, OPT_ARMARIO_LACADO, 3);
const after = engine.getPrice()!.subtotal;
// Con 2 puertas ya sumaba 700 en el estado inicial
// Con 3 puertas suma 900 → diferencia de 200
expect(after - base).toBe(200);
});
});
// ============================================================================
// resolveView
// ============================================================================
describe('resolveView', () => {
it('devuelve una URL para una vista válida', () => {
const result = engine.resolveView(SEC_SALON, VIEW_FRONT);
expect(typeof result.url).toBe('string');
expect(result.url.length).toBeGreaterThan(0);
});
it('la URL cambia al cambiar una opción visual', () => {
const before = engine.resolveView(SEC_SALON, VIEW_FRONT).url;
engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
const after = engine.resolveView(SEC_SALON, VIEW_FRONT).url;
expect(after).not.toBe(before);
});
it('devuelve isFallback false para vista válida', () => {
const result = engine.resolveView(SEC_SALON, VIEW_FRONT);
expect(result.isFallback).toBe(false);
});
it('devuelve isFallback true sin objeto activo', () => {
const eng = makeEngine();
const result = eng.resolveView(SEC_SALON, VIEW_FRONT);
expect(result.isFallback).toBe(true);
});
});
// ============================================================================
// isValid
// ============================================================================
describe('isValid', () => {
it('estado inicial es válido', () => {
expect(engine.isValid()).toBe(true);
});
it('true sin objeto activo', () => {
const eng = makeEngine();
expect(eng.isValid()).toBe(true);
});
});
// ============================================================================
// onChange — eventos
// ============================================================================
describe('onChange', () => {
it('devuelve función de cleanup', () => {
const unsubscribe = engine.onChange(vi.fn());
expect(typeof unsubscribe).toBe('function');
});
it('cleanup elimina el listener', () => {
const listener = vi.fn();
const unsubscribe = engine.onChange(listener);
unsubscribe();
engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
expect(listener).not.toHaveBeenCalled();
});
it('múltiples listeners reciben el mismo evento', () => {
const a = vi.fn();
const b = vi.fn();
engine.onChange(a);
engine.onChange(b);
engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
expect(a).toHaveBeenCalledTimes(1);
expect(b).toHaveBeenCalledTimes(1);
});
it('error en listener no rompe el engine', () => {
engine.onChange(() => { throw new Error('listener crash'); });
expect(() => engine.select(ATTR_SUELO, OPT_SUELO_PARQUET)).not.toThrow();
});
});

@ -11,7 +11,7 @@ import type { SelectionMap } from '../engines/rule';
import { import {
CATALOGO_VIVIENDAS, CATALOGO_VIVIENDAS,
IDS_TEST, IDS_TEST,
} from './housing-catalog.fixture'; } from '../fixtures/housing-catalog.ts';
const { const {
OBJ_APARTAMENTO, OBJ_APARTAMENTO,
@ -36,6 +36,7 @@ const {
OPT_ARMARIO_MADERA, OPT_ARMARIO_MADERA,
} = IDS_TEST; } = IDS_TEST;
// ============================================================================ // ============================================================================
// SETUP // SETUP
// ============================================================================ // ============================================================================
@ -57,15 +58,15 @@ function makeLogr(): Logr {
/** Estado base con armario lacado 2 puertas */ /** Estado base con armario lacado 2 puertas */
function baseState(): SelectionMap { function baseState(): SelectionMap {
return { return {
[ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR, [ATTR_CALIDAD] : { optionId: OPT_CALIDAD_ESTANDAR },
[ATTR_SUELO] : OPT_SUELO_CERAMICA, [ATTR_SUELO] : { optionId: OPT_SUELO_CERAMICA },
[ATTR_PARED] : OPT_PARED_BLANCO, [ATTR_PARED] : { optionId: OPT_PARED_BLANCO },
[ATTR_SANITARIO] : OPT_SANIT_BLANCO, [ATTR_SANITARIO] : { optionId: OPT_SANIT_BLANCO },
[ATTR_REVEST_BANO] : OPT_REVEST_CERAMICA, [ATTR_REVEST_BANO] : { optionId: OPT_REVEST_CERAMICA },
[ATTR_ENCIMERA] : OPT_ENCIMERA_GRANITO, [ATTR_ENCIMERA] : { optionId: OPT_ENCIMERA_GRANITO },
[ATTR_MUEBLE_COC] : OPT_MUEBLE_BLANCO, [ATTR_MUEBLE_COC] : { optionId: OPT_MUEBLE_BLANCO },
[ATTR_PUERTA] : OPT_PUERTA_LACADA, [ATTR_PUERTA] : { optionId: OPT_PUERTA_LACADA },
[ATTR_ARMARIO_PUERTAS] : OPT_ARMARIO_LACADO, [ATTR_ARMARIO_PUERTAS] : { optionId: OPT_ARMARIO_LACADO, quantity: 2 },
}; };
} }
@ -103,7 +104,7 @@ describe('dynamicExpression — armario lacado', () => {
describe('dynamicExpression — armario madera', () => { describe('dynamicExpression — armario madera', () => {
it('2 puertas madera → 500 × 2 = 1000', () => { it('2 puertas madera → 500 × 2 = 1000', () => {
const state = { ...baseState(), [ATTR_ARMARIO_PUERTAS]: OPT_ARMARIO_MADERA }; const state = { ...baseState(), [ATTR_ARMARIO_PUERTAS]: { optionId: OPT_ARMARIO_MADERA, quantity: 2 } };
const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS);
const entry = result.breakdown.find(e => e.attrId === ATTR_ARMARIO_PUERTAS); const entry = result.breakdown.find(e => e.attrId === ATTR_ARMARIO_PUERTAS);
expect(entry?.amount).toBe(1000); expect(entry?.amount).toBe(1000);
@ -118,27 +119,15 @@ describe('dynamicExpression — armario madera', () => {
describe('QuantifiableAttribute — cantidad', () => { describe('QuantifiableAttribute — cantidad', () => {
it('3 puertas lacadas → 300 × 3 = 900 (descuento volumen)', () => { it('3 puertas lacadas → 300 × 3 = 900 (descuento volumen)', () => {
// Modificamos el fixture en memoria cambiando la cantidad del atributo const state = { ...baseState(), [ATTR_ARMARIO_PUERTAS]: { optionId: OPT_ARMARIO_LACADO, quantity: 3 } };
const catalogWith3Doors = structuredClone(CATALOGO_VIVIENDAS); const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS);
const dormSection = catalogWith3Doors.objects[OBJ_APARTAMENTO]
.sections['sc:armario' as any] as any;
const attr = dormSection.attributes.find((a: any) => a.id === ATTR_ARMARIO_PUERTAS);
attr.quantity = 3;
const result = engine.calculate(OBJ_APARTAMENTO, baseState(), catalogWith3Doors);
const entry = result.breakdown.find(e => e.attrId === ATTR_ARMARIO_PUERTAS); const entry = result.breakdown.find(e => e.attrId === ATTR_ARMARIO_PUERTAS);
expect(entry?.amount).toBe(900); expect(entry?.amount).toBe(900);
}); });
it('3 puertas madera → 500 × 3 = 1500', () => { it('3 puertas madera → 500 × 3 = 1500', () => {
const catalogWith3Doors = structuredClone(CATALOGO_VIVIENDAS); const state = { ...baseState(), [ATTR_ARMARIO_PUERTAS]: { optionId: OPT_ARMARIO_MADERA, quantity: 3 } };
const dormSection = catalogWith3Doors.objects[OBJ_APARTAMENTO] const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS);
.sections['sc:armario' as any] as any;
const attr = dormSection.attributes.find((a: any) => a.id === ATTR_ARMARIO_PUERTAS);
attr.quantity = 3;
const state = { ...baseState(), [ATTR_ARMARIO_PUERTAS]: OPT_ARMARIO_MADERA };
const result = engine.calculate(OBJ_APARTAMENTO, state, catalogWith3Doors);
const entry = result.breakdown.find(e => e.attrId === ATTR_ARMARIO_PUERTAS); const entry = result.breakdown.find(e => e.attrId === ATTR_ARMARIO_PUERTAS);
expect(entry?.amount).toBe(1500); expect(entry?.amount).toBe(1500);
}); });
@ -156,22 +145,22 @@ describe('QuantifiableAttribute — cantidad', () => {
}); });
}); });
// ============================================================================
// dynamicExpression — fallback a baseAmount si la expresión falla
// ============================================================================
describe('dynamicExpression — fallback a baseAmount', () => { describe('dynamicExpression — fallback a baseAmount', () => {
it('expresión malformada → usa baseAmount y logea error', () => { it('expresión malformada → cae a baseAmount y el amount es 0', () => {
const logr = makeLogr(); const logr = makeLogr();
const eng = new PricingEngine(logr); const eng = new PricingEngine(logr);
const broken = structuredClone(CATALOGO_VIVIENDAS); const broken = structuredClone(CATALOGO_VIVIENDAS);
// Romper la dynamicExpression en AttributeOption (tiene precedencia)
const sec = broken.objects[OBJ_APARTAMENTO].sections['sc:armario' as any] as any;
const attr = sec.attributes.find((a: any) => a.id === ATTR_ARMARIO_PUERTAS);
attr.options[0].dynamicExpression = { 'operadorFalso': null };
// También romper la de OptionDefinition por si acaso
const opt = broken.options[OPT_ARMARIO_LACADO] as any; const opt = broken.options[OPT_ARMARIO_LACADO] as any;
opt.pricing.dynamicExpression = { 'operadorFalso': null }; opt.pricing.dynamicExpression = { 'operadorFalso': null };
// operadorFalso devuelve null → no es number → cae a baseAmount (0) // operadorFalso → null → no es number → cae a baseAmount = 0 → no aparece en breakdown
const result = eng.calculate(OBJ_APARTAMENTO, baseState(), broken); const result = eng.calculate(OBJ_APARTAMENTO, baseState(), broken);
const entry = result.breakdown.find(e => e.attrId === ATTR_ARMARIO_PUERTAS); const entry = result.breakdown.find(e => e.attrId === ATTR_ARMARIO_PUERTAS);
// baseAmount es 0 → no aparece en breakdown
expect(entry).toBeUndefined(); expect(entry).toBeUndefined();
}); });
}); });

@ -3,15 +3,16 @@
* PRICING ENGINE — TESTS * PRICING ENGINE — TESTS
* ============================================================================ * ============================================================================
*/ */
import type { Logr } from '@/libs/logr';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PricingEngine } from '../engines/pricing'; import type { Logr } from '@/libs/logr';
import type { SelectionMap } from '../engines/rule'; import type { SelectionMap } from '../engines';
import type { TaxInfo } from '../types'; import type { TaxInfo } from '../types';
import { PricingEngine } from '../engines';
import { import {
CATALOGO_VIVIENDAS, CATALOGO_VIVIENDAS,
IDS_TEST, IDS_TEST,
} from './housing-catalog.fixture'; } from '@/libs/vice/fixtures';
const { const {
OBJ_APARTAMENTO, OBJ_APARTAMENTO,
@ -23,6 +24,7 @@ const {
ATTR_ENCIMERA, ATTR_ENCIMERA,
ATTR_MUEBLE_COC, ATTR_MUEBLE_COC,
ATTR_PUERTA, ATTR_PUERTA,
ATTR_M2_SALON,
OPT_CALIDAD_ESTANDAR, OPT_CALIDAD_ESTANDAR,
OPT_CALIDAD_PREMIUM, OPT_CALIDAD_PREMIUM,
OPT_CALIDAD_LUJO, OPT_CALIDAD_LUJO,
@ -62,14 +64,14 @@ function makeLogr(): Logr {
/** Estado base — opciones de precio conocido para verificar sumas */ /** Estado base — opciones de precio conocido para verificar sumas */
function baseState(): SelectionMap { function baseState(): SelectionMap {
return { return {
[ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR, // 0 [ATTR_CALIDAD] : { optionId: OPT_CALIDAD_ESTANDAR }, // 0
[ATTR_SUELO] : OPT_SUELO_CERAMICA, // 30 [ATTR_SUELO] : { optionId: OPT_SUELO_CERAMICA }, // 30
[ATTR_PARED] : OPT_PARED_BLANCO, // 0 [ATTR_PARED] : { optionId: OPT_PARED_BLANCO }, // 0
[ATTR_SANITARIO] : OPT_SANIT_BLANCO, // 0 [ATTR_SANITARIO] : { optionId: OPT_SANIT_BLANCO }, // 0
[ATTR_REVEST_BANO]: OPT_REVEST_CERAMICA, // 40 [ATTR_REVEST_BANO]: { optionId: OPT_REVEST_CERAMICA }, // 40
[ATTR_ENCIMERA] : OPT_ENCIMERA_GRANITO, // 400 [ATTR_ENCIMERA] : { optionId: OPT_ENCIMERA_GRANITO }, // 400
[ATTR_MUEBLE_COC] : OPT_MUEBLE_BLANCO, // 3500 [ATTR_MUEBLE_COC] : { optionId: OPT_MUEBLE_BLANCO }, // 3500
[ATTR_PUERTA] : OPT_PUERTA_LACADA, // 250 [ATTR_PUERTA] : { optionId: OPT_PUERTA_LACADA }, // 250
}; };
// subtotal base = 30 + 40 + 400 + 3500 + 250 = 4220 // subtotal base = 30 + 40 + 400 + 3500 + 250 = 4220
} }
@ -112,38 +114,38 @@ describe('cálculo básico', () => {
}); });
it('calidad premium suma 15000 al total', () => { it('calidad premium suma 15000 al total', () => {
const state = { ...baseState(), [ATTR_CALIDAD]: OPT_CALIDAD_PREMIUM }; // +15000 const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_PREMIUM } }; // +15000
const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS);
expect(result.subtotal).toBe(4220 + 15000); expect(result.subtotal).toBe(4220 + 15000);
}); });
it('calidad lujo suma 35000 al total', () => { it('calidad lujo suma 35000 al total', () => {
const state = { ...baseState(), [ATTR_CALIDAD]: OPT_CALIDAD_LUJO }; // +35000 const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_LUJO } }; // +35000
const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS);
expect(result.subtotal).toBe(4220 + 35000); expect(result.subtotal).toBe(4220 + 35000);
}); });
it('cambiar suelo de cerámica a parquet suma 30 - 30 + 45', () => { it('cambiar suelo de cerámica a parquet suma 30 - 30 + 45', () => {
const base = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS); const base = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS);
const state = { ...baseState(), [ATTR_SUELO]: OPT_SUELO_PARQUET }; // 45 en lugar de 30 const state = { ...baseState(), [ATTR_SUELO]: { optionId: OPT_SUELO_PARQUET } }; // 45 en lugar de 30
const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS);
expect(result.subtotal).toBe(base.subtotal - 30 + 45); expect(result.subtotal).toBe(base.subtotal - 30 + 45);
}); });
it('pared efecto piedra añade 800', () => { it('pared efecto piedra añade 800', () => {
const state = { ...baseState(), [ATTR_PARED]: OPT_PARED_PIEDRA }; // +800 const state = { ...baseState(), [ATTR_PARED]: { optionId: OPT_PARED_PIEDRA } }; // +800
const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS);
expect(result.subtotal).toBe(4220 + 800); expect(result.subtotal).toBe(4220 + 800);
}); });
it('sanitario negro añade 1200', () => { it('sanitario negro añade 1200', () => {
const state = { ...baseState(), [ATTR_SANITARIO]: OPT_SANIT_NEGRO }; // +1200 const state = { ...baseState(), [ATTR_SANITARIO]: { optionId: OPT_SANIT_NEGRO } }; // +1200
const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS);
expect(result.subtotal).toBe(4220 + 1200); expect(result.subtotal).toBe(4220 + 1200);
}); });
it('puerta cristal en lugar de lacada — diferencia de 250', () => { it('puerta cristal en lugar de lacada — diferencia de 250', () => {
const state = { ...baseState(), [ATTR_PUERTA]: OPT_PUERTA_CRISTAL }; // 500 en lugar de 250 const state = { ...baseState(), [ATTR_PUERTA]: { optionId: OPT_PUERTA_CRISTAL } }; // 500 en lugar de 250
const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS);
expect(result.subtotal).toBe(4220 - 250 + 500); expect(result.subtotal).toBe(4220 - 250 + 500);
}); });
@ -228,7 +230,7 @@ describe('breakdown', () => {
it('el breakdown refleja el cambio de opción', () => { it('el breakdown refleja el cambio de opción', () => {
const base = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS); const base = engine.calculate(OBJ_APARTAMENTO, baseState(), CATALOGO_VIVIENDAS);
const state = { ...baseState(), [ATTR_REVEST_BANO]: OPT_REVEST_MARMOL }; // 120 en lugar de 40 const state = { ...baseState(), [ATTR_REVEST_BANO]: { optionId: OPT_REVEST_MARMOL } }; // 120 en lugar de 40
const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS);
const baseEntry = base.breakdown.find(e => e.attrId === ATTR_REVEST_BANO); const baseEntry = base.breakdown.find(e => e.attrId === ATTR_REVEST_BANO);
@ -263,7 +265,7 @@ describe('casos límite', () => {
}); });
it('encimera silestone suma 600', () => { it('encimera silestone suma 600', () => {
const state = { ...baseState(), [ATTR_ENCIMERA]: OPT_ENCIMERA_SILESTONE }; const state = { ...baseState(), [ATTR_ENCIMERA]: { optionId: OPT_ENCIMERA_SILESTONE } };
const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS); const result = engine.calculate(OBJ_APARTAMENTO, state, CATALOGO_VIVIENDAS);
const entry = result.breakdown.find(e => e.attrId === ATTR_ENCIMERA); const entry = result.breakdown.find(e => e.attrId === ATTR_ENCIMERA);
expect(entry?.amount).toBe(600); expect(entry?.amount).toBe(600);

@ -0,0 +1,329 @@
/**
* ============================================================================
* RULE ENGINE — TESTS
* ============================================================================
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RuleEngine } from '../engines/rule';
import type { Logr } from '@/libs/logr';
import type { SelectionMap } from '../engines/rule';
import {
CATALOGO_VIVIENDAS,
IDS_TEST
} from '../fixtures/housing-catalog.ts';
const {
ATTR_CALIDAD,
ATTR_SUELO,
ATTR_REVEST_BANO,
ATTR_SANITARIO,
ATTR_ENCIMERA,
ATTR_PARED,
ATTR_PUERTA,
OPT_CALIDAD_ESTANDAR,
OPT_CALIDAD_PREMIUM,
OPT_CALIDAD_LUJO,
OPT_SUELO_MICROCEMENTO,
OPT_SUELO_PARQUET,
OPT_REVEST_MARMOL,
OPT_REVEST_MICROCEMENTO,
OPT_REVEST_CERAMICA,
OPT_SANIT_NEGRO,
OPT_SANIT_BLANCO,
OPT_ENCIMERA_MADERA,
OPT_ENCIMERA_GRANITO,
OPT_PARED_BLANCO,
OPT_PUERTA_LACADA,
} = IDS_TEST;
// ============================================================================
// SETUP
// ============================================================================
function makeLogr(): Logr {
return {
debug : vi.fn(),
info : vi.fn(),
warn : vi.fn(),
error : vi.fn(),
getLogs : vi.fn(),
clear : vi.fn(),
serialize: vi.fn(),
setLevel : vi.fn(),
setMaxLogs: vi.fn(),
} as unknown as Logr;
}
const rules = Object.values(CATALOGO_VIVIENDAS.rules ?? {});
/**
* Estado base válido — todas las opciones compatibles entre sí.
* Calidad premium + parquet + pared blanca + sanitario blanco +
* revestimiento cerámica + encimera granito + puerta lacada.
*/
function baseState(): SelectionMap {
return {
[ATTR_CALIDAD] : OPT_CALIDAD_PREMIUM,
[ATTR_SUELO] : OPT_SUELO_PARQUET,
[ATTR_PARED] : OPT_PARED_BLANCO,
[ATTR_SANITARIO] : OPT_SANIT_BLANCO,
[ATTR_REVEST_BANO]: { optionId: OPT_REVEST_CERAMICA },
[ATTR_ENCIMERA] : OPT_ENCIMERA_GRANITO,
[ATTR_PUERTA] : OPT_PUERTA_LACADA,
};
}
let engine: RuleEngine;
beforeEach(() => {
engine = new RuleEngine(makeLogr());
});
// ============================================================================
// isValid — estado base
// ============================================================================
describe('isValid', () => {
it('estado base compatible es válido', () => {
expect(engine.isValid(rules, baseState())).toBe(true);
});
it('calidad estándar + mármol → inválido (error)', () => {
const state = {
...baseState(),
[ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR,
[ATTR_REVEST_BANO]: { optionId: OPT_REVEST_MARMOL },
};
expect(engine.isValid(rules, state)).toBe(false);
});
it('sanitario negro + calidad estándar → inválido (error)', () => {
const state = {
...baseState(),
[ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR,
[ATTR_SANITARIO]: { optionId: OPT_SANIT_NEGRO },
};
expect(engine.isValid(rules, state)).toBe(false);
});
it('sanitario negro + calidad premium → válido', () => {
const state = {
...baseState(),
[ATTR_CALIDAD] : OPT_CALIDAD_PREMIUM,
[ATTR_SANITARIO]: { optionId: OPT_SANIT_NEGRO },
};
expect(engine.isValid(rules, state)).toBe(true);
});
it('encimera madera + sanitario negro → inválido (warning no bloquea)', () => {
// warning no bloquea isValid — solo los errores lo hacen
const state = {
...baseState(),
[ATTR_ENCIMERA] : OPT_ENCIMERA_MADERA,
[ATTR_SANITARIO]: { optionId: OPT_SANIT_NEGRO },
};
// severity: 'warning' → isValid sigue siendo true
expect(engine.isValid(rules, state)).toBe(true);
});
it('estado vacío sin reglas activas → válido', () => {
expect(engine.isValid([], baseState())).toBe(true);
});
});
// ============================================================================
// getViolations — detección de violaciones
// ============================================================================
describe('getViolations', () => {
it('estado base compatible → sin violaciones', () => {
expect(engine.getViolations(rules, baseState())).toHaveLength(0);
});
it('calidad estándar + mármol → violación de error', () => {
const state = {
...baseState(),
[ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR,
[ATTR_REVEST_BANO]: { optionId: OPT_REVEST_MARMOL },
};
const violations = engine.getViolations(rules, state);
expect(violations.length).toBeGreaterThan(0);
expect(violations[0].severity).toBe('error');
});
it('las violaciones se ordenan: error primero, warning después', () => {
// sanitario negro + calidad estándar genera error (negro_requiere_premium)
// encimera madera + sanitario negro genera warning (madera_no_bano)
const state = {
...baseState(),
[ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR,
[ATTR_SANITARIO]: { optionId: OPT_SANIT_NEGRO },
[ATTR_ENCIMERA] : OPT_ENCIMERA_MADERA,
};
const violations = engine.getViolations(rules, state);
const severities = violations.map(v => v.severity);
const errorIdx = severities.indexOf('error');
const warnIdx = severities.indexOf('warning');
if (errorIdx !== -1 && warnIdx !== -1) {
expect(errorIdx).toBeLessThan(warnIdx);
}
});
it('calidad lujo sin mármol → violación de warning (require)', () => {
const state = {
...baseState(),
[ATTR_CALIDAD] : OPT_CALIDAD_LUJO,
[ATTR_REVEST_BANO]: { optionId: OPT_REVEST_CERAMICA },
};
const violations = engine.getViolations(rules, state);
expect(violations.some(v => v.severity === 'warning')).toBe(true);
});
it('contiene referencia a la regla violada', () => {
const state = {
...baseState(),
[ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR,
[ATTR_REVEST_BANO]: { optionId: OPT_REVEST_MARMOL },
};
const violations = engine.getViolations(rules, state);
expect(violations[0].rule).toBeDefined();
expect(violations[0].rule.id).toBeTruthy();
});
});
// ============================================================================
// isValueAllowed
// ============================================================================
describe('isValueAllowed', () => {
it('mármol permitido con calidad premium', () => {
const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_PREMIUM } };
expect(engine.isValueAllowed(ATTR_REVEST_BANO, OPT_REVEST_MARMOL, rules, state)).toBe(true);
});
it('mármol prohibido con calidad estándar', () => {
const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_ESTANDAR } };
expect(engine.isValueAllowed(ATTR_REVEST_BANO, OPT_REVEST_MARMOL, rules, state)).toBe(false);
});
it('sanitario negro prohibido con calidad estándar', () => {
const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_ESTANDAR } };
expect(engine.isValueAllowed(ATTR_SANITARIO, OPT_SANIT_NEGRO, rules, state)).toBe(false);
});
it('sanitario negro permitido con calidad premium', () => {
const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_PREMIUM } };
expect(engine.isValueAllowed(ATTR_SANITARIO, OPT_SANIT_NEGRO, rules, state)).toBe(true);
});
it('microcemento en baño permitido con suelo microcemento (allow)', () => {
const state = { ...baseState(), [ATTR_SUELO]: { optionId: OPT_SUELO_MICROCEMENTO } };
expect(engine.isValueAllowed(ATTR_REVEST_BANO, OPT_REVEST_MICROCEMENTO, rules, state)).toBe(true);
});
it('mármol en baño no permitido con suelo microcemento (allow limita lista)', () => {
const state = { ...baseState(), [ATTR_SUELO]: { optionId: OPT_SUELO_MICROCEMENTO } };
expect(engine.isValueAllowed(ATTR_REVEST_BANO, OPT_REVEST_MARMOL, rules, state)).toBe(false);
});
it('cualquier valor permitido para atributo sin reglas activas', () => {
expect(engine.isValueAllowed(ATTR_PARED, OPT_PARED_BLANCO, rules, baseState())).toBe(true);
});
});
// ============================================================================
// getAllowedValues
// ============================================================================
describe('getAllowedValues', () => {
it('devuelve undefined si no hay restricción de allow activa', () => {
const state = { ...baseState(), [ATTR_SUELO]: { optionId: OPT_SUELO_PARQUET } };
expect(engine.getAllowedValues(ATTR_REVEST_BANO, rules, state)).toBeUndefined();
});
it('con suelo microcemento devuelve lista restringida para revest_bano', () => {
const state = { ...baseState(), [ATTR_SUELO]: { optionId: OPT_SUELO_MICROCEMENTO } };
const allowed = engine.getAllowedValues(ATTR_REVEST_BANO, rules, state);
expect(allowed).toBeDefined();
expect(allowed).toContain(OPT_REVEST_MICROCEMENTO);
expect(allowed).toContain(OPT_REVEST_CERAMICA);
expect(allowed).not.toContain(OPT_REVEST_MARMOL);
});
});
// ============================================================================
// isRequired
// ============================================================================
describe('isRequired', () => {
it('revest_bano no requerido con calidad premium', () => {
const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_PREMIUM } };
expect(engine.isRequired(ATTR_REVEST_BANO, rules, state)).toBe(false);
});
it('revest_bano requerido con calidad lujo', () => {
const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_LUJO } };
expect(engine.isRequired(ATTR_REVEST_BANO, rules, state)).toBe(true);
});
it('atributo sin regla de require → no requerido', () => {
expect(engine.isRequired(ATTR_PARED, rules, baseState())).toBe(false);
});
});
// ============================================================================
// evaluateAll — prioridad
// ============================================================================
describe('evaluateAll — prioridad', () => {
it('las reglas se devuelven ordenadas por prioridad descendente', () => {
const results = engine.evaluateAll(rules, baseState());
const priorities = results.map(r => r.rule.priority);
for (let i = 0; i < priorities.length - 1; i++) {
expect(priorities[i]).toBeGreaterThanOrEqual(priorities[i + 1]);
}
});
it('regla con condición que no se cumple → triggered: false', () => {
// Con calidad premium la regla estandar_no_lujo no se activa
const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_PREMIUM } };
const results = engine.evaluateAll(rules, state);
const estandarNoLujo = results.find(r => r.rule.id === IDS_TEST.RULE_ESTANDAR_NO_LUJO);
expect(estandarNoLujo?.triggered).toBe(false);
});
it('regla con condición que se cumple → triggered: true', () => {
const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_ESTANDAR } };
const results = engine.evaluateAll(rules, state);
const estandarNoLujo = results.find(r => r.rule.id === IDS_TEST.RULE_ESTANDAR_NO_LUJO);
expect(estandarNoLujo?.triggered).toBe(true);
});
});
// ============================================================================
// evaluateRule — error handling
// ============================================================================
describe('evaluateRule — error handling', () => {
it('regla con condición malformada → triggered: false y logea error', () => {
const logr = makeLogr();
const eng = new RuleEngine(logr);
const badRule = {
...Object.values(CATALOGO_VIVIENDAS.rules ?? {})[0],
id : 'rl:test_bad' as any,
condition: { 'operadorQueNoExiste': null } as any,
};
const result = eng.evaluateRule(badRule, baseState());
// El evaluador devuelve null para operadores desconocidos → triggered: false
expect(result.triggered).toBe(false);
});
});

@ -10,7 +10,7 @@ import type { Logr } from '@/libs/logr';
import { import {
CATALOGO_VIVIENDAS, CATALOGO_VIVIENDAS,
IDS_TEST, IDS_TEST,
} from './housing-catalog.fixture'; } from '../fixtures/housing-catalog.ts';
const { const {
OBJ_APARTAMENTO, OBJ_APARTAMENTO,
@ -33,6 +33,7 @@ const {
OPT_REVEST_MICROCEMENTO, OPT_REVEST_MICROCEMENTO,
} = IDS_TEST; } = IDS_TEST;
// ============================================================================ // ============================================================================
// SETUP // SETUP
// ============================================================================ // ============================================================================
@ -69,12 +70,12 @@ describe('inicialización', () => {
it('carga los defaultValues de todos los atributos', () => { it('carga los defaultValues de todos los atributos', () => {
const state = makeState(); const state = makeState();
// Todos los atributos dynamic tienen defaultValue en el fixture // getValue devuelve OptionSelection — comparamos el optionId
expect(state.getValue(ATTR_CALIDAD)).toBe(OPT_CALIDAD_ESTANDAR); expect((state.getValue(ATTR_CALIDAD) as any).optionId).toBe(OPT_CALIDAD_ESTANDAR);
expect(state.getValue(ATTR_SUELO)).toBe(OPT_SUELO_CERAMICA); expect((state.getValue(ATTR_SUELO) as any).optionId).toBe(OPT_SUELO_CERAMICA);
expect(state.getValue(ATTR_PARED)).toBe(OPT_PARED_BLANCO); expect((state.getValue(ATTR_PARED) as any).optionId).toBe(OPT_PARED_BLANCO);
expect(state.getValue(ATTR_SANITARIO)).toBe(OPT_SANIT_BLANCO); expect((state.getValue(ATTR_SANITARIO) as any).optionId).toBe(OPT_SANIT_BLANCO);
expect(state.getValue(ATTR_REVEST_BANO)).toBe(OPT_REVEST_CERAMICA); expect((state.getValue(ATTR_REVEST_BANO) as any).optionId).toBe(OPT_REVEST_CERAMICA);
}); });
it('el estado inicial es válido', () => { it('el estado inicial es válido', () => {
@ -120,7 +121,7 @@ describe('select — valores permitidos', () => {
const snapshot = state.getSelection(); const snapshot = state.getSelection();
state.select(ATTR_SUELO, OPT_SUELO_PARQUET); state.select(ATTR_SUELO, OPT_SUELO_PARQUET);
// snapshot no debe haber cambiado // snapshot no debe haber cambiado
expect(snapshot[ATTR_SUELO]).toBe(OPT_SUELO_CERAMICA); expect((snapshot[ATTR_SUELO] as any).optionId).toBe(OPT_SUELO_CERAMICA);
}); });
}); });
@ -139,7 +140,7 @@ describe('select — valores prohibidos', () => {
it('el valor no cambia al rechazar', () => { it('el valor no cambia al rechazar', () => {
const state = makeState(); const state = makeState();
state.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL); state.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL);
expect(state.getValue(ATTR_REVEST_BANO)).toBe(OPT_REVEST_CERAMICA); expect((state.getValue(ATTR_REVEST_BANO) as any).optionId).toBe(OPT_REVEST_CERAMICA);
}); });
it('rechaza sanitario negro con calidad estándar', () => { it('rechaza sanitario negro con calidad estándar', () => {
@ -217,7 +218,7 @@ describe('getAttributeState', () => {
it('devuelve el valor actual del atributo', () => { it('devuelve el valor actual del atributo', () => {
const state = makeState(); const state = makeState();
const s = state.getAttributeState(ATTR_SUELO); const s = state.getAttributeState(ATTR_SUELO);
expect(s?.value).toBe(OPT_SUELO_CERAMICA); expect((s?.value as any).optionId).toBe(OPT_SUELO_CERAMICA);
}); });
it('devuelve las opciones del atributo', () => { it('devuelve las opciones del atributo', () => {
@ -298,4 +299,3 @@ describe('getAllAttributeStates', () => {
expect([...states.keys()].filter(k => k === ATTR_SUELO)).toHaveLength(1); expect([...states.keys()].filter(k => k === ATTR_SUELO)).toHaveLength(1);
}); });
}); });

@ -11,7 +11,7 @@ import type { SelectionMap } from '../engines/rule';
import { import {
CATALOGO_VIVIENDAS, CATALOGO_VIVIENDAS,
IDS_TEST, IDS_TEST,
} from './housing-catalog.fixture'; } from '../fixtures/housing-catalog.ts';
const { const {
OBJ_APARTAMENTO, OBJ_APARTAMENTO,

@ -14,19 +14,14 @@ import type { JsonLogic } from './json-logic';
// ATTRIBUTE DISPLAY // ATTRIBUTE DISPLAY
// ============================================================================ // ============================================================================
/**
* Configuración de presentación del atributo en UI.
* Los flags pueden ser estáticos o evaluados dinámicamente con JsonLogic.
*/
export interface AttributeDisplay { export interface AttributeDisplay {
/** Si se muestra en UI */ /** Si se muestra en UI */
uiVisible? : boolean | JsonLogic; uiVisible? : boolean | JsonLogic;
/** /**
* Si el valor de este atributo afecta la imagen renderizada. * Si el valor afecta la imagen renderizada.
* Los atributos con affectsVisual: true son candidatos a aparecer * Candidatos a aparecer como placeholders en templates de vista.
* como placeholders en los templates de vista.
*/ */
affectsVisual? : boolean | JsonLogic; affectsVisual?: boolean | JsonLogic;
/** Si afecta al precio */ /** Si afecta al precio */
affectsPrice? : boolean | JsonLogic; affectsPrice? : boolean | JsonLogic;
/** Si es de solo lectura */ /** Si es de solo lectura */
@ -58,10 +53,9 @@ export type AttributeCategory =
export interface BaseAttribute { export interface BaseAttribute {
id : AttrID; id : AttrID;
/** /**
* Identificador legible usado en placeholders de templates. * Identificador legible para placeholders de templates.
* Debe ser único dentro del scope (sección u objeto). * Único dentro del scope (sección u objeto).
* * @example 'suelo' | 'pintura' | 'luz'
* @example 'suelo' | 'pintura' | 'carpinteria'
*/ */
code : Code; code : Code;
name : I18nString; name : I18nString;
@ -71,9 +65,67 @@ export interface BaseAttribute {
metadata? : Metadata; metadata? : Metadata;
} }
// ============================================================================
// QUANTITY CONFIG
// Configuración de cantidad para opciones cuantificables.
// La cantidad vive en AttributeOption — es la OPCIÓN la que es cuantificable,
// no el atributo.
//
// Ejemplo:
// Atributo: 'iluminación'
// Opciones:
// - 'luz_analogica' → quantity: undefined (no cuantificable)
// - 'luz_led' → quantity: { default: 1, min: 1, max: 4, step: 1, unit: 'puntos' }
// - 'puertas_lacado' → quantity: { default: 2, min: 2, max: 3, step: 1, unit: 'puertas' }
// ============================================================================
export interface QuantityConfig {
/** Valor por defecto */
default : number;
/** Mínimo permitido */
min : number;
/** Máximo permitido */
max : number;
/** Incremento — por defecto 1 */
step? : number;
/** Unidad de medida para mostrar en UI */
unit? : I18nString;
}
// ============================================================================
// ATTRIBUTE OPTION
// ============================================================================
export interface AttributeOption {
optionId: OptionID;
priority?: number;
/**
* Si está presente, esta opción es cuantificable.
* El usuario elige la opción Y una cantidad dentro del rango.
* La cantidad se usa en Pricing.dynamicExpression como { var: 'quantity' }.
*/
quantity?: QuantityConfig;
/**
* Override de precio fijo para esta opción en este atributo concreto.
* Tiene precedencia sobre Pricing.baseAmount de la OptionDefinition.
*/
pricingOverride?: number;
/**
* Override de expresión dinámica para esta opción en este atributo concreto.
* Tiene precedencia sobre Pricing.dynamicExpression de la OptionDefinition.
* Contexto disponible: { quantity, baseAmount, attributes }
*/
dynamicExpression?: JsonLogic;
metadata?: Metadata;
}
// ============================================================================ // ============================================================================
// FIXED ATTRIBUTE // FIXED ATTRIBUTE
// Valor fijo — no lo elige el usuario // Valor fijo — no lo elige el usuario. Ej: m² de una habitación.
// ============================================================================ // ============================================================================
export interface FixedAttribute extends BaseAttribute { export interface FixedAttribute extends BaseAttribute {
@ -85,13 +137,19 @@ export interface FixedAttribute extends BaseAttribute {
// ============================================================================ // ============================================================================
// DYNAMIC ATTRIBUTE // DYNAMIC ATTRIBUTE
// El usuario elige entre opciones del catálogo // El usuario elige entre opciones del catálogo.
// Si alguna opción tiene quantity, el usuario también elige la cantidad.
// ============================================================================ // ============================================================================
export interface DynamicAttribute extends BaseAttribute { export interface DynamicAttribute extends BaseAttribute {
type : 'dynamic'; type : 'dynamic';
dataType : 'reference'; dataType : 'reference';
defaultValue : OptionID; defaultValue: OptionID;
/**
* Cantidad por defecto para la opción defaultValue,
* si esta opción es cuantificable.
*/
defaultQuantity?: number;
options : AttributeOption[]; options : AttributeOption[];
required? : boolean; required? : boolean;
/** /**
@ -99,62 +157,21 @@ export interface DynamicAttribute extends BaseAttribute {
* Ejemplo: elegir 'con_terraza' activa la sección 'sc:terraza' * Ejemplo: elegir 'con_terraza' activa la sección 'sc:terraza'
*/ */
controls? : SectionID[]; controls? : SectionID[];
/**
* Expresión para filtrar opciones disponibles dinámicamente.
* Complementa al RuleEngine para casos de filtrado simple.
*/
filterExpression?: JsonLogic; filterExpression?: JsonLogic;
/**
* Si el usuario puede modificar la cantidad de la opción seleccionada.
* Por defecto true si la opción seleccionada tiene quantity.
*/
userConfigurableQuantity?: boolean;
} }
export interface AttributeOption {
optionId : OptionID;
priority? : number;
pricingOverride?: number;
metadata? : Metadata;
}
// ============================================================================
// QUANTIFIABLE ATTRIBUTE
// Atributo con cantidad — puede ser puramente numérico o una opción con cantidad
//
// Dos variantes:
//
// 1. Numérico puro (dataType: 'number'):
// Valor numérico directo, sin opciones. Ej: m² de suelo, vatios de iluminación.
//
// 2. Opción con cantidad (dataType: 'reference'):
// El usuario elige una opción del catálogo Y una cantidad.
// Ej: armario con 2 o 3 puertas lacadas.
// El precio se calcula con dynamicExpression usando { quantity, ...state }
// ============================================================================
export interface QuantifiableNumericAttribute extends BaseAttribute {
type : 'quantifiable';
dataType : 'number';
quantity : number;
minQuantity? : number;
maxQuantity? : number;
unit? : I18nString;
userConfigurable?: boolean;
pricePerUnit? : number;
}
export interface QuantifiableOptionAttribute extends BaseAttribute {
type : 'quantifiable';
dataType : 'reference';
quantity : number;
minQuantity? : number;
maxQuantity? : number;
unit? : I18nString;
userConfigurable?: boolean;
defaultValue : OptionID;
options : AttributeOption[];
required? : boolean;
}
export type QuantifiableAttribute =
| QuantifiableNumericAttribute
| QuantifiableOptionAttribute;
// ============================================================================ // ============================================================================
// COMPUTED ATTRIBUTE // COMPUTED ATTRIBUTE
// Calculado a partir de otros atributos — nunca editable por el usuario // Calculado a partir de otros atributos — nunca editable por el usuario.
// ============================================================================ // ============================================================================
export interface ComputedAttribute extends BaseAttribute { export interface ComputedAttribute extends BaseAttribute {
@ -172,5 +189,4 @@ export interface ComputedAttribute extends BaseAttribute {
export type Attribute = export type Attribute =
| FixedAttribute | FixedAttribute
| DynamicAttribute | DynamicAttribute
| QuantifiableAttribute
| ComputedAttribute; | ComputedAttribute;

@ -16,14 +16,3 @@ export type HotspotID = ID<'hs'>;
export type CatalogID = ID<'ct'>; export type CatalogID = ID<'ct'>;
// ============================================================================
// TYPE GUARDS
// ============================================================================
export function isOptionID(value: unknown): value is OptionID {
return typeof value === 'string' && value.startsWith('op:');
}
export function isAttrID(value: unknown): value is AttrID {
return typeof value === 'string' && value.startsWith('at:');
}

@ -1,16 +1,22 @@
export * from './primitives.ts';
export * from './ids.ts';
export * from './datas.ts';
export * from './api.ts'; export * from './api.ts';
export * from './attribute.ts'; export * from './attribute.ts';
export * from './catalog.ts';
export * from './datas.ts';
export * from './hotspot.ts'; export * from './hotspot.ts';
export * from './ids.ts';
export * from './json-logic.ts'; export * from './json-logic.ts';
export * from './object.ts';
export * from './option.ts'; export * from './option.ts';
export * from './pricing.ts'; export * from './pricing.ts';
export * from './primitives.ts';
export * from './rule.ts'; export * from './rule.ts';
export * from './section.ts'; export * from './section.ts';
export * from './selection.ts';
export * from './view.ts'; export * from './view.ts';
export * from './object.ts';
export * from './catalog.ts';

@ -0,0 +1,41 @@
// ============================================================================
// OPTION SELECTION
// ============================================================================
import type {AttrID, OptionID} from "./ids.ts";
import type {Value} from "./primitives";
// ============================================================================
// OPTION SELECTION
// ============================================================================
/**
* Selección de una opción — optionId + cantidad opcional.
* La cantidad solo está presente si la AttributeOption tiene QuantityConfig.
*
* @example
* // Opción simple
* { optionId: 'op:suelo_parquet' }
*
* // Opción cuantificable
* { optionId: 'op:luz_led', quantity: 3 }
*/
export interface OptionSelection {
optionId : OptionID;
quantity?: number;
}
// ============================================================================
// SELECTION MAP
// ============================================================================
/**
* Estado de selección del usuario.
* Clave: AttrID — Valor: OptionSelection o Value (para FixedAttribute)
*
* Los atributos FixedAttribute y ComputedAttribute usan Value directamente.
* Los atributos DynamicAttribute usan OptionSelection.
*/
export type SelectionMap = Record<AttrID, OptionSelection | Value>;
Loading…
Cancel
Save

Powered by TurnKey Linux.