First commit

master
dev 8 months ago
parent e81ecc8e14
commit 4c0e0a33f0

54
package-lock.json generated

@ -1,17 +1,16 @@
{
"name": "cpq-engine",
"version": "1.0.0",
"name": "visual-configuration-engine",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cpq-engine",
"version": "1.0.0",
"license": "ISC",
"name": "visual-configuration-engine",
"version": "2.0.0",
"license": "EULA",
"devDependencies": {
"@types/node": "^25.2.3",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
}
@ -1202,19 +1201,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -1318,16 +1304,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/rollup": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
@ -1492,26 +1468,6 @@
}
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",

@ -10,11 +10,13 @@
"typecheck": "tsc --noEmit",
"dev": "tsc --watch"
},
"keywords": ["configuration", "visual", "typescript"],
"keywords": [
"configuration",
"visual",
"typescript"
],
"author": "ACTIVE THING",
"license": "EULA",
"devDependencies": {
"@types/node": "^25.2.3",
"ts-node": "^10.9.2",
@ -22,4 +24,3 @@
"vitest": "^4.0.18"
}
}

@ -0,0 +1,322 @@
/**
* ============================================================================
* RULE ENGINE
* ============================================================================
*
* Evalúa ValidationRules usando JsonLogic
* Determina qué atributos están permitidos/prohibidos/requeridos
*/
import type { ValidationRule, ValidationRuleAction } from '@/types/model';
import type { AttrID, OptionID } from '@/types/model';
import type { Value } from '@/types/core';
import type { Severity } from '@/types/core';
import { JsonLogicEvaluator } from '@/engine/json-logic/evaluator.ts';
import { logger , ERRORS, MESSAGE_CATEGORIES } from '@/messages';
// ============================================================================
// TYPES
// ============================================================================
/**
* Estado actual de selección del usuario
* Mapa de attrId → valor seleccionado
*/
export type SelectionState = Record<AttrID, Value>;
/**
* Resultado de evaluar una regla
*/
export interface RuleEvaluationResult {
rule : ValidationRule;
triggered : boolean;
action? : ValidationRuleAction;
}
/**
* Resultado consolidado para un atributo
*/
export interface AttributeRuleResult {
attrId : AttrID;
allowedValues? : OptionID[];
forbiddenValues?: OptionID[];
required : boolean;
violations : RuleViolation[];
}
/**
* Violación de una regla
*/
export interface RuleViolation {
rule : ValidationRule;
severity: Severity;
}
// ============================================================================
// RULE ENGINE
// ============================================================================
export class RuleEngine {
private evaluator = new JsonLogicEvaluator();
/**
* Evalúa todas las reglas contra el estado actual
*/
evaluateAll(
rules : ValidationRule[],
state : SelectionState
): RuleEvaluationResult[] {
// Ordenar por prioridad (mayor prioridad primero)
const sorted = [...rules].sort((a, b) => b.priority - a.priority);
return sorted.map(rule => this.evaluateRule(rule, state));
}
/**
* Evalúa una regla concreta
*/
evaluateRule(
rule : ValidationRule,
state: SelectionState
): RuleEvaluationResult {
try {
const data = { attributes: normalizeStateKeys(state) };
const directTriggered = Boolean(this.evaluator.evaluate(rule.condition, data));
if (directTriggered) {
return { rule, triggered: true, action: rule.action };
}
if (rule.bidirectional && this.evaluateInverse(rule, state)) {
// ✅ Acción invertida: el origen de la condición debe restringirse
// Extraemos qué atributo/valor disparó la condición inversa
// y construimos la forbid al revés
const invertedAction: ValidationRuleAction = {
type : 'forbid',
targetAttr: rule.action.targetAttr,
values : rule.action.values
};
return { rule, triggered: true, action: invertedAction };
}
return { rule, triggered: false };
} catch (error) {
logger.error(
MESSAGE_CATEGORIES.VALIDATION,
ERRORS.UNKNOWN_OPERATOR(String(error)),
{ ruleId: rule.id }
);
return { rule, triggered: false };
}
}
/**
* Evalúa condición inversa para reglas bidireccionales.
* La inversa es: si el targetAttr tiene actualmente un valor prohibido,
* entonces la condición del origen también debería estar restringida.
*
* SOLO genera triggered si hay un valor prohibido actualmente seleccionado
* en el targetAttr Y la condición directa NO se cumplió.
*/
private evaluateInverse(
rule : ValidationRule,
state: SelectionState
): boolean {
if (rule.action.type !== 'forbid') return false;
// La inversa comprueba si el targetAttr tiene un valor prohibido
// Y si es así, actúa como si la condición se hubiera cumplido al revés
const currentTargetValue = state[rule.action.targetAttr];
// ¿Tiene el targetAttr seleccionado algún valor que esta regla prohíbe?
const targetHasForbiddenValue = currentTargetValue !== undefined &&
rule.action.values.includes(currentTargetValue);
if (!targetHasForbiddenValue) return false;
// ¿La condición directa ya lo cubre? Si no, activar inversa
const data = { attributes: normalizeStateKeys(state) };
return !Boolean(this.evaluator.evaluate(rule.condition, data));
}
getAttributeResults(
rules : ValidationRule[],
state : SelectionState
): Map<AttrID, AttributeRuleResult> {
const results = new Map<AttrID, AttributeRuleResult>();
const evaluated = this.evaluateAll(rules, state);
for (const { triggered, action, rule } of evaluated) {
if (!triggered || !action) continue;
// Determinar si se activó por condición directa o inversa
const data = { attributes: normalizeStateKeys(state) };
const directTriggered = Boolean(this.evaluator.evaluate(rule.condition, data));
// Si es bidireccional y se activó por INVERSA,
// la restricción es sobre el atributo que aparece en la CONDICIÓN, no en la acción
if (rule.bidirectional && !directTriggered) {
// La inversa prohíbe al "origen" de la condición tener el valor que disparó
// En este caso simplemente registramos la violación en el targetAttr original
// porque el valor prohibido (calidad=estandar) ya está seleccionado
const attrId = action.targetAttr;
if (!results.has(attrId)) {
results.set(attrId, {
attrId,
allowedValues : undefined,
forbiddenValues: [],
required : false,
violations : []
});
}
const result = results.get(attrId)!;
const currentValue = state[attrId];
// Solo violación si el valor actual está en la lista prohibida
if (currentValue !== undefined && action.values.includes(currentValue)) {
result.violations.push({ rule, severity: rule.severity });
}
result.forbiddenValues = [
...(result.forbiddenValues || []),
...(action.values as OptionID[])
];
continue;
}
// Flujo normal (condición directa)
const attrId = action.targetAttr;
if (!results.has(attrId)) {
results.set(attrId, {
attrId,
allowedValues : undefined,
forbiddenValues: [],
required : false,
violations : []
});
}
const result = results.get(attrId)!;
switch (action.type) {
case 'allow':
if (result.allowedValues) {
result.allowedValues = result.allowedValues.filter(
v => action.values.includes(v)
);
} else {
result.allowedValues = action.values as OptionID[];
}
break;
case 'forbid':
result.forbiddenValues = [
...(result.forbiddenValues || []),
...(action.values as OptionID[])
];
if (action.values.includes(state[attrId])) {
result.violations.push({ rule, severity: rule.severity });
}
break;
case 'require':
result.required = true;
if (!state[attrId]) {
result.violations.push({ rule, severity: rule.severity });
}
break;
case 'suggest':
break;
}
}
return results;
}
/**
* Verifica si un valor concreto está permitido para un atributo
*/
isValueAllowed(
attrId : AttrID,
value : Value,
rules : ValidationRule[],
state : SelectionState
): boolean {
const results = this.getAttributeResults(rules, state);
const result = results.get(attrId);
if (!result) return true; // Sin reglas → permitido
// Verificar si está prohibido
if (result.forbiddenValues?.includes(value as OptionID)) return false;
// Verificar si hay lista de permitidos y el valor no está
if (result.allowedValues && !result.allowedValues.includes(value as OptionID)) return false;
return true;
}
/**
* Obtiene todas las violaciones del estado actual
*/
getViolations(
rules: ValidationRule[],
state: SelectionState
): RuleViolation[] {
const results = this.getAttributeResults(rules, state);
const violations: RuleViolation[] = [];
for (const result of results.values()) {
violations.push(...result.violations);
}
// Ordenar por severidad: error primero, luego warning, luego info
return violations.sort((a, b) =>
severityOrder(a.severity) - severityOrder(b.severity)
);
}
/**
* Verifica si el estado actual es válido (sin violaciones de error)
*/
isValid(
rules: ValidationRule[],
state: SelectionState
): boolean {
const violations = this.getViolations(rules, state);
return !violations.some(v => v.severity === 'error');
}
}
// ============================================================================
// HELPERS
// ============================================================================
function severityOrder(severity: Severity): number {
switch (severity) {
case 'error' : return 0;
case 'warning': return 1;
case 'info' : return 2;
default : return 3;
}
}
function normalizeStateKeys(state: SelectionState): Record<string, Value> {
const normalized: Record<string, Value> = {};
for (const [key, value] of Object.entries(state)) {
normalized[key.replace(':', '_')] = value; // 'at:calidad' → 'at_calidad'
}
return normalized;
}
// ============================================================================
// SINGLETON
// ============================================================================
export const ruleEngine = new RuleEngine();

@ -0,0 +1,374 @@
/**
* ============================================================================
* JSON LOGIC EVALUATOR - TESTS
* ============================================================================
*/
import { describe, it, expect } from 'vitest';
import { JsonLogicEvaluator } from '@/engine/json-logic/evaluator';
const evaluator = new JsonLogicEvaluator();
const eval_ = (expr: any, data: any = {}) => evaluator.evaluate(expr, data);
// ============================================================================
// PRIMITIVOS
// ============================================================================
describe('Primitivos', () => {
it('retorna null', () => {
expect(eval_(null)).toBeNull();
});
it('retorna boolean', () => {
expect(eval_(true)).toBe(true);
expect(eval_(false)).toBe(false);
});
it('retorna number', () => {
expect(eval_(42)).toBe(42);
expect(eval_(0)).toBe(0);
expect(eval_(-5)).toBe(-5);
});
it('retorna string', () => {
expect(eval_('hola')).toBe('hola');
expect(eval_('')).toBe('');
});
});
// ============================================================================
// VAR
// ============================================================================
describe('var', () => {
it('accede a propiedad simple', () => {
expect(eval_({ var: 'nombre' }, { nombre: 'Juan' })).toBe('Juan');
});
it('accede a propiedad anidada con punto', () => {
expect(
eval_({ var: 'user.age' }, { user: { age: 30 } })
).toBe(30);
});
it('accede a propiedad profundamente anidada', () => {
expect(
eval_({ var: 'a.b.c' }, { a: { b: { c: 'deep' } } })
).toBe('deep');
});
it('retorna undefined si la propiedad no existe', () => {
expect(eval_({ var: 'noExiste' }, {})).toBeUndefined();
});
it('retorna undefined si el path intermedio es null', () => {
expect(eval_({ var: 'a.b' }, { a: null })).toBeUndefined();
});
});
// ============================================================================
// IGUALDAD
// ============================================================================
describe('==', () => {
it('compara valores iguales', () => {
expect(eval_({ '==': [1, 1] })).toBe(true);
expect(eval_({ '==': ['a', 'a'] })).toBe(true);
expect(eval_({ '==': [true, true] })).toBe(true);
});
it('detecta valores distintos', () => {
expect(eval_({ '==': [1, 2] })).toBe(false);
expect(eval_({ '==': ['a', 'b'] })).toBe(false);
});
it('compara variables', () => {
expect(
eval_({ '==': [{ var: 'tipo' }, 'premium'] }, { tipo: 'premium' })
).toBe(true);
expect(
eval_({ '==': [{ var: 'tipo' }, 'premium'] }, { tipo: 'basic' })
).toBe(false);
});
});
describe('===', () => {
it('compara con igualdad estricta', () => {
expect(eval_({ '===': [1, 1] })).toBe(true);
expect(eval_({ '===': [1, 2] })).toBe(false);
});
});
describe('!=', () => {
it('detecta desigualdad', () => {
expect(eval_({ '!=': [1, 2] })).toBe(true);
expect(eval_({ '!=': [1, 1] })).toBe(false);
});
});
describe('!==', () => {
it('detecta desigualdad estricta', () => {
expect(eval_({ '!==': [1, 2] })).toBe(true);
expect(eval_({ '!==': [1, 1] })).toBe(false);
});
});
// ============================================================================
// NEGACIÓN
// ============================================================================
describe('!', () => {
it('niega un valor booleano', () => {
expect(eval_({ '!': true })).toBe(false);
expect(eval_({ '!': false })).toBe(true);
});
it('niega expresiones', () => {
expect(eval_({ '!': { '==': [1, 2] } })).toBe(true);
expect(eval_({ '!': { '==': [1, 1] } })).toBe(false);
});
});
// ============================================================================
// LÓGICOS
// ============================================================================
describe('and', () => {
it('retorna true si todos son true', () => {
expect(eval_({ and: [true, true, true] })).toBe(true);
});
it('retorna false si alguno es false', () => {
expect(eval_({ and: [true, false, true] })).toBe(false);
});
it('funciona con expresiones', () => {
expect(eval_({
and: [
{ '==': [1, 1] },
{ '==': [2, 2] }
]
})).toBe(true);
});
});
describe('or', () => {
it('retorna true si alguno es true', () => {
expect(eval_({ or: [false, true, false] })).toBe(true);
});
it('retorna false si todos son false', () => {
expect(eval_({ or: [false, false, false] })).toBe(false);
});
it('funciona con expresiones', () => {
expect(eval_({
or: [
{ '==': [1, 2] },
{ '==': [2, 2] }
]
})).toBe(true);
});
});
// ============================================================================
// COMPARACIÓN
// ============================================================================
describe('>', () => {
it('compara mayor que', () => {
expect(eval_({ '>': [5, 3] })).toBe(true);
expect(eval_({ '>': [3, 5] })).toBe(false);
expect(eval_({ '>': [5, 5] })).toBe(false);
});
it('funciona con variables', () => {
expect(
eval_({ '>': [{ var: 'precio' }, 100] }, { precio: 150 })
).toBe(true);
});
});
describe('>=', () => {
it('compara mayor o igual', () => {
expect(eval_({ '>=': [5, 5] })).toBe(true);
expect(eval_({ '>=': [5, 3] })).toBe(true);
expect(eval_({ '>=': [3, 5] })).toBe(false);
});
});
describe('<', () => {
it('compara menor que', () => {
expect(eval_({ '<': [3, 5] })).toBe(true);
expect(eval_({ '<': [5, 3] })).toBe(false);
expect(eval_({ '<': [5, 5] })).toBe(false);
});
});
describe('<=', () => {
it('compara menor o igual', () => {
expect(eval_({ '<=': [5, 5] })).toBe(true);
expect(eval_({ '<=': [3, 5] })).toBe(true);
expect(eval_({ '<=': [5, 3] })).toBe(false);
});
});
// ============================================================================
// CONDICIONAL
// ============================================================================
describe('if', () => {
it('retorna rama then si la condición es true', () => {
expect(eval_({ if: [true, 'si', 'no'] })).toBe('si');
});
it('retorna rama else si la condición es false', () => {
expect(eval_({ if: [false, 'si', 'no'] })).toBe('no');
});
it('retorna null si no hay rama else y la condición es false', () => {
expect(eval_({ if: [false, 'si'] })).toBeNull();
});
it('evalúa condición dinámicamente', () => {
const expr = {
if: [
{ '>': [{ var: 'edad' }, 18] },
'adulto',
'menor'
]
};
expect(eval_(expr, { edad: 25 })).toBe('adulto');
expect(eval_(expr, { edad: 15 })).toBe('menor');
});
});
// ============================================================================
// IN
// ============================================================================
describe('in', () => {
it('retorna true si el valor está en el array', () => {
expect(eval_({ in: ['roble', ['roble', 'pino', 'haya']] })).toBe(true);
});
it('retorna false si el valor no está en el array', () => {
expect(eval_({ in: ['cedro', ['roble', 'pino', 'haya']] })).toBe(false);
});
it('funciona con variables', () => {
expect(
eval_(
{ in: [{ var: 'material' }, ['roble', 'pino']] },
{ material: 'roble' }
)
).toBe(true);
});
});
// ============================================================================
// ARITMÉTICA
// ============================================================================
describe('+', () => {
it('suma números', () => {
expect(eval_({ '+': [2, 3] })).toBe(5);
expect(eval_({ '+': [1, 2, 3] })).toBe(6);
});
it('retorna 0 para array vacío', () => {
expect(eval_({ '+': [] })).toBe(0);
});
});
describe('-', () => {
it('resta dos números', () => {
expect(eval_({ '-': [5, 3] })).toBe(2);
});
it('niega un solo número', () => {
expect(eval_({ '-': [5] })).toBe(-5);
});
});
describe('*', () => {
it('multiplica números', () => {
expect(eval_({ '*': [3, 4] })).toBe(12);
expect(eval_({ '*': [2, 3, 4] })).toBe(24);
});
});
describe('/', () => {
it('divide dos números', () => {
expect(eval_({ '/': [10, 2] })).toBe(5);
});
it('retorna null al dividir por cero', () => {
expect(eval_({ '/': [10, 0] })).toBeNull();
});
});
describe('%', () => {
it('calcula el módulo', () => {
expect(eval_({ '%': [10, 3] })).toBe(1);
expect(eval_({ '%': [10, 5] })).toBe(0);
});
});
// ============================================================================
// EXPRESIONES COMPLEJAS (casos reales del sistema)
// ============================================================================
describe('Expresiones complejas', () => {
it('filtra atributo por tipo de material', () => {
const expr = {
and: [
{ '==': [{ var: 'attributes.acabado' }, 'premium'] },
{ in: [{ var: 'attributes.material' }, ['roble', 'haya']] }
]
};
expect(eval_(expr, {
attributes: { acabado: 'premium', material: 'roble' }
})).toBe(true);
expect(eval_(expr, {
attributes: { acabado: 'basic', material: 'roble' }
})).toBe(false);
expect(eval_(expr, {
attributes: { acabado: 'premium', material: 'pino' }
})).toBe(false);
});
it('calcula precio con condición', () => {
const expr = {
if: [
{ '>': [{ var: 'cantidad' }, 10] },
{ '*': [{ var: 'precio' }, 0.9] }, // 10% descuento
{ var: 'precio' }
]
};
expect(eval_(expr, { cantidad: 15, precio: 100 })).toBe(90);
expect(eval_(expr, { cantidad: 5, precio: 100 })).toBe(100);
});
it('visibilidad condicional de sección', () => {
const expr = {
or: [
{ '==': [{ var: 'tipo' }, 'completo'] },
{ and: [
{ '==': [{ var: 'tipo' }, 'modular'] },
{ '>': [{ var: 'modulos' }, 2] }
]}
]
};
expect(eval_(expr, { tipo: 'completo', modulos: 1 })).toBe(true);
expect(eval_(expr, { tipo: 'modular', modulos: 3 })).toBe(true);
expect(eval_(expr, { tipo: 'modular', modulos: 1 })).toBe(false);
expect(eval_(expr, { tipo: 'basico', modulos: 5 })).toBe(false);
});
});

@ -0,0 +1,800 @@
/**
* ============================================================================
* CATÁLOGO DE VIVIENDAS - FIXTURE COMPLETO
* ============================================================================
*
* Modelo A: Apartamento (1 salón, 1 baño, 1 dormitorio, 1 cocina)
* Modelo B: Dúplex (1 salón, 1 baño, 1 aseo, 2 dormitorios)
*/
import type {
ConfigurationCatalog,
ConfigurableObject,
OptionDefinition,
ValidationRule,
Section,
DynamicAttribute,
FixedAttribute,
} from '@/types/model';
import type { AttrID, SectionID, OptionID, ObjectID, RuleID } from '@/types/model';
// ============================================================================
// IDs - OPCIONES
// ============================================================================
// Suelos
const OPT_SUELO_PARQUET : OptionID = 'op:suelo_parquet';
const OPT_SUELO_PORCELANICO : OptionID = 'op:suelo_porcelanico';
const OPT_SUELO_MICROCEMENTO: OptionID = 'op:suelo_microcemento';
const OPT_SUELO_CERAMICA : OptionID = 'op:suelo_ceramica';
// Paredes
const OPT_PARED_BLANCO : OptionID = 'op:pared_blanco';
const OPT_PARED_GRIS : OptionID = 'op:pared_gris';
const OPT_PARED_BEIGE : OptionID = 'op:pared_beige';
const OPT_PARED_PIEDRA : OptionID = 'op:pared_piedra';
// Puertas
const OPT_PUERTA_ROBLE : OptionID = 'op:puerta_roble';
const OPT_PUERTA_LACADA : OptionID = 'op:puerta_lacada';
const OPT_PUERTA_CRISTAL : OptionID = 'op:puerta_cristal';
// Cocina encimeras
const OPT_ENCIMERA_GRANITO : OptionID = 'op:encimera_granito';
const OPT_ENCIMERA_SILESTONE: OptionID = 'op:encimera_silestone';
const OPT_ENCIMERA_MADERA : OptionID = 'op:encimera_madera';
// Cocina muebles
const OPT_MUEBLE_BLANCO : OptionID = 'op:mueble_blanco';
const OPT_MUEBLE_GRIS : OptionID = 'op:mueble_gris';
const OPT_MUEBLE_MADERA : OptionID = 'op:mueble_madera';
// Baño sanitarios
const OPT_SANIT_BLANCO : OptionID = 'op:sanit_blanco';
const OPT_SANIT_NEGRO : OptionID = 'op:sanit_negro';
// Baño revestimiento
const OPT_REVEST_MARMOL : OptionID = 'op:revest_marmol';
const OPT_REVEST_MICROCEMENTO: OptionID = 'op:revest_microcemento';
const OPT_REVEST_CERAMICA : OptionID = 'op:revest_ceramica';
// Calidad acabados generales
const OPT_CALIDAD_ESTANDAR : OptionID = 'op:calidad_estandar';
const OPT_CALIDAD_PREMIUM : OptionID = 'op:calidad_premium';
const OPT_CALIDAD_LUJO : OptionID = 'op:calidad_lujo';
// ============================================================================
// IDs - ATRIBUTOS
// ============================================================================
// Globales (afectan a toda la vivienda)
const ATTR_CALIDAD : AttrID = 'at:calidad';
const ATTR_PUERTA : AttrID = 'at:puerta';
// Suelos
const ATTR_SUELO : AttrID = 'at:suelo';
// Paredes
const ATTR_PARED : AttrID = 'at:pared';
// Cocina
const ATTR_ENCIMERA : AttrID = 'at:encimera';
const ATTR_MUEBLE_COC : AttrID = 'at:mueble_cocina';
const ATTR_M2_COCINA : AttrID = 'at:m2_cocina';
// Baño / Aseo
const ATTR_SANITARIO : AttrID = 'at:sanitario';
const ATTR_REVEST_BANO: AttrID = 'at:revest_bano';
const ATTR_M2_BANO : AttrID = 'at:m2_bano';
// Dormitorio
const ATTR_M2_DORM : AttrID = 'at:m2_dormitorio';
// Salón
const ATTR_M2_SALON : AttrID = 'at:m2_salon';
// ============================================================================
// IDs - SECCIONES
// ============================================================================
const SEC_SALON : SectionID = 'sc:salon';
const SEC_BANO : SectionID = 'sc:bano';
const SEC_ASEO : SectionID = 'sc:aseo';
const SEC_DORMITORIO1 : SectionID = 'sc:dormitorio1';
const SEC_DORMITORIO2 : SectionID = 'sc:dormitorio2';
const SEC_COCINA : SectionID = 'sc:cocina';
// ============================================================================
// IDs - OBJETOS
// ============================================================================
const OBJ_APARTAMENTO : ObjectID = 'ob:apartamento';
const OBJ_DUPLEX : ObjectID = 'ob:duplex';
// ============================================================================
// IDs - REGLAS
// ============================================================================
const RULE_LUJO_MARMOL : RuleID = 'rl:lujo_requiere_marmol';
const RULE_ESTANDAR_NO_LUJO : RuleID = 'rl:estandar_no_lujo';
const RULE_MICRO_CONSISTENTE: RuleID = 'rl:microcemento_consistente';
const RULE_MADERA_NO_BANO : RuleID = 'rl:madera_no_bano';
const RULE_NEGRO_PREMIUM : RuleID = 'rl:negro_requiere_premium';
const RULE_ESTANDAR_NO_NEGRO : RuleID = 'rl:estandar_no_negro';
// ============================================================================
// OPCIONES - DEFINICIONES
// ============================================================================
export const OPTIONS: Record<OptionID, OptionDefinition> = {
// Suelos
[OPT_SUELO_PARQUET]: {
id : OPT_SUELO_PARQUET,
name : { es: 'Parquet', en: 'Parquet' },
description: { es: 'Suelo de madera natural', en: 'Natural wood floor' },
pricing : { baseAmount: 45 },
tags : ['madera', 'calido']
},
[OPT_SUELO_PORCELANICO]: {
id : OPT_SUELO_PORCELANICO,
name : { es: 'Porcelánico', en: 'Porcelain tile' },
description: { es: 'Suelo porcelánico de gran formato', en: 'Large format porcelain tile' },
pricing : { baseAmount: 55 },
tags : ['ceramica', 'moderno']
},
[OPT_SUELO_MICROCEMENTO]: {
id : OPT_SUELO_MICROCEMENTO,
name : { es: 'Microcemento', en: 'Microcement' },
description: { es: 'Microcemento continuo', en: 'Continuous microcement' },
pricing : { baseAmount: 75 },
tags : ['moderno', 'premium']
},
[OPT_SUELO_CERAMICA]: {
id : OPT_SUELO_CERAMICA,
name : { es: 'Cerámica', en: 'Ceramic tile' },
description: { es: 'Cerámica estándar', en: 'Standard ceramic tile' },
pricing : { baseAmount: 30 },
tags : ['ceramica', 'estandar']
},
// Paredes
[OPT_PARED_BLANCO]: {
id : OPT_PARED_BLANCO,
name : { es: 'Blanco roto', en: 'Off white' },
description: { es: 'Pintura blanca rota', en: 'Off white paint' },
pricing : { baseAmount: 0 }
},
[OPT_PARED_GRIS]: {
id : OPT_PARED_GRIS,
name : { es: 'Gris perla', en: 'Pearl grey' },
description: { es: 'Pintura gris perla', en: 'Pearl grey paint' },
pricing : { baseAmount: 0 }
},
[OPT_PARED_BEIGE]: {
id : OPT_PARED_BEIGE,
name : { es: 'Beige', en: 'Beige' },
description: { es: 'Pintura beige', en: 'Beige paint' },
pricing : { baseAmount: 0 }
},
[OPT_PARED_PIEDRA]: {
id : OPT_PARED_PIEDRA,
name : { es: 'Efecto piedra', en: 'Stone effect' },
description: { es: 'Pintura efecto piedra', en: 'Stone effect paint' },
pricing : { baseAmount: 800 }
},
// Puertas
[OPT_PUERTA_ROBLE]: {
id : OPT_PUERTA_ROBLE,
name : { es: 'Roble natural', en: 'Natural oak' },
description: { es: 'Puerta en madera de roble', en: 'Oak wood door' },
pricing : { baseAmount: 350 }
},
[OPT_PUERTA_LACADA]: {
id : OPT_PUERTA_LACADA,
name : { es: 'Lacada blanca', en: 'White lacquered' },
description: { es: 'Puerta lacada en blanco', en: 'White lacquered door' },
pricing : { baseAmount: 250 }
},
[OPT_PUERTA_CRISTAL]: {
id : OPT_PUERTA_CRISTAL,
name : { es: 'Cristal', en: 'Glass' },
description: { es: 'Puerta de cristal templado', en: 'Tempered glass door' },
pricing : { baseAmount: 500 }
},
// Encimeras
[OPT_ENCIMERA_GRANITO]: {
id : OPT_ENCIMERA_GRANITO,
name : { es: 'Granito', en: 'Granite' },
description: { es: 'Encimera de granito natural', en: 'Natural granite countertop' },
pricing : { baseAmount: 400 }
},
[OPT_ENCIMERA_SILESTONE]: {
id : OPT_ENCIMERA_SILESTONE,
name : { es: 'Silestone', en: 'Silestone' },
description: { es: 'Encimera de Silestone', en: 'Silestone countertop' },
pricing : { baseAmount: 600 }
},
[OPT_ENCIMERA_MADERA]: {
id : OPT_ENCIMERA_MADERA,
name : { es: 'Madera', en: 'Wood' },
description: { es: 'Encimera de madera maciza', en: 'Solid wood countertop' },
pricing : { baseAmount: 500 }
},
// Muebles cocina
[OPT_MUEBLE_BLANCO]: {
id : OPT_MUEBLE_BLANCO,
name : { es: 'Blanco mate', en: 'Matte white' },
description: { es: 'Muebles lacados en blanco mate', en: 'Matte white lacquered cabinets' },
pricing : { baseAmount: 3500 }
},
[OPT_MUEBLE_GRIS]: {
id : OPT_MUEBLE_GRIS,
name : { es: 'Gris antracita', en: 'Anthracite grey' },
description: { es: 'Muebles en gris antracita', en: 'Anthracite grey cabinets' },
pricing : { baseAmount: 4000 }
},
[OPT_MUEBLE_MADERA]: {
id : OPT_MUEBLE_MADERA,
name : { es: 'Madera natural', en: 'Natural wood' },
description: { es: 'Muebles en madera natural', en: 'Natural wood cabinets' },
pricing : { baseAmount: 5000 }
},
// Sanitarios
[OPT_SANIT_BLANCO]: {
id : OPT_SANIT_BLANCO,
name : { es: 'Blanco', en: 'White' },
description: { es: 'Sanitarios blancos', en: 'White sanitary ware' },
pricing : { baseAmount: 0 }
},
[OPT_SANIT_NEGRO]: {
id : OPT_SANIT_NEGRO,
name : { es: 'Negro mate', en: 'Matte black' },
description: { es: 'Sanitarios en negro mate', en: 'Matte black sanitary ware' },
pricing : { baseAmount: 1200 }
},
// Revestimiento baño
[OPT_REVEST_MARMOL]: {
id : OPT_REVEST_MARMOL,
name : { es: 'Mármol', en: 'Marble' },
description: { es: 'Revestimiento de mármol natural', en: 'Natural marble cladding' },
pricing : { baseAmount: 120 }
},
[OPT_REVEST_MICROCEMENTO]: {
id : OPT_REVEST_MICROCEMENTO,
name : { es: 'Microcemento', en: 'Microcement' },
description: { es: 'Revestimiento de microcemento', en: 'Microcement cladding' },
pricing : { baseAmount: 90 }
},
[OPT_REVEST_CERAMICA]: {
id : OPT_REVEST_CERAMICA,
name : { es: 'Cerámica', en: 'Ceramic tile' },
description: { es: 'Revestimiento cerámico', en: 'Ceramic tile cladding' },
pricing : { baseAmount: 40 }
},
// Calidad
[OPT_CALIDAD_ESTANDAR]: {
id : OPT_CALIDAD_ESTANDAR,
name : { es: 'Estándar', en: 'Standard' },
description: { es: 'Acabados estándar de calidad', en: 'Standard quality finishes' },
pricing : { baseAmount: 0 }
},
[OPT_CALIDAD_PREMIUM]: {
id : OPT_CALIDAD_PREMIUM,
name : { es: 'Premium', en: 'Premium' },
description: { es: 'Acabados de alta calidad', en: 'High quality finishes' },
pricing : { baseAmount: 15000 }
},
[OPT_CALIDAD_LUJO]: {
id : OPT_CALIDAD_LUJO,
name : { es: 'Lujo', en: 'Luxury' },
description: { es: 'Acabados de lujo exclusivos', en: 'Exclusive luxury finishes' },
pricing : { baseAmount: 35000 }
},
};
// ============================================================================
// ATRIBUTOS COMUNES (reutilizados en múltiples secciones)
// ============================================================================
const attrSuelo = (required: boolean = true): DynamicAttribute => ({
id : ATTR_SUELO,
name : { es: 'Suelo', en: 'Flooring' },
description : { es: 'Material del suelo', en: 'Floor material' },
type : 'dynamic',
dataType : 'reference',
defaultValue: OPT_SUELO_CERAMICA,
options : [
{ optionId: OPT_SUELO_CERAMICA, priority: 1 },
{ optionId: OPT_SUELO_PARQUET, priority: 2 },
{ optionId: OPT_SUELO_PORCELANICO, priority: 3 },
{ optionId: OPT_SUELO_MICROCEMENTO, priority: 4 }
],
required,
display : { uiVisible: true, affectsVisual: true, affectsPrice: true }
});
const attrPared = (): DynamicAttribute => ({
id : ATTR_PARED,
name : { es: 'Color paredes', en: 'Wall color' },
description : { es: 'Color de la pintura de paredes', en: 'Wall paint color' },
type : 'dynamic',
dataType : 'reference',
defaultValue: OPT_PARED_BLANCO,
options : [
{ optionId: OPT_PARED_BLANCO, priority: 1 },
{ optionId: OPT_PARED_GRIS, priority: 2 },
{ optionId: OPT_PARED_BEIGE, priority: 3 },
{ optionId: OPT_PARED_PIEDRA, priority: 4 }
],
display : { uiVisible: true, affectsVisual: true, affectsPrice: true }
});
// ============================================================================
// SECCIONES
// ============================================================================
const seccionSalon = (): Section => ({
id : SEC_SALON,
name : { es: 'Salón', en: 'Living room' },
description : { es: 'Salón - comedor principal', en: 'Main living - dining room' },
attrs : [
attrSuelo(),
attrPared(),
{
id : ATTR_M2_SALON,
name : { es: 'Superficie', en: 'Surface' },
description : { es: 'Superficie del salón en m²', en: 'Living room surface in m²' },
type : 'fixed',
dataType : 'number',
value : 25,
unit : { es: 'm²', en: 'sqm' },
display : { uiVisible: false, readonly: true }
} as FixedAttribute,
{
id : ATTR_PUERTA,
name : { es: 'Puerta', en: 'Door' },
description : { es: 'Tipo de puerta', en: 'Door type' },
type : 'dynamic',
dataType : 'reference',
defaultValue: OPT_PUERTA_LACADA,
options : [
{ optionId: OPT_PUERTA_LACADA, priority: 1 },
{ optionId: OPT_PUERTA_ROBLE, priority: 2 },
{ optionId: OPT_PUERTA_CRISTAL, priority: 3 }
],
display : { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute
],
visualConfig: {
strategy : 'static_image',
imageUrlTemplate : '{basePath}/salon/{at:suelo}/{at:pared}/view.jpg',
basePath : '/renders/vivienda',
globalAttrDependencies : [ATTR_CALIDAD],
sectionAttrDependencies: [ATTR_SUELO, ATTR_PARED]
},
availability: { mode: 'required' }
});
const seccionBano = (m2: number = 6): Section => ({
id : SEC_BANO,
name : { es: 'Baño', en: 'Bathroom' },
description : { es: 'Baño principal', en: 'Main bathroom' },
attrs : [
{
id : ATTR_SANITARIO,
name : { es: 'Sanitarios', en: 'Sanitary ware' },
description : { es: 'Color de los sanitarios', en: 'Sanitary ware color' },
type : 'dynamic',
dataType : 'reference',
defaultValue: OPT_SANIT_BLANCO,
options : [
{ optionId: OPT_SANIT_BLANCO, priority: 1 },
{ optionId: OPT_SANIT_NEGRO, priority: 2 }
],
display : { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
{
id : ATTR_REVEST_BANO,
name : { es: 'Revestimiento', en: 'Cladding' },
description : { es: 'Revestimiento de paredes y suelo', en: 'Wall and floor cladding' },
type : 'dynamic',
dataType : 'reference',
defaultValue: OPT_REVEST_CERAMICA,
options : [
{ optionId: OPT_REVEST_CERAMICA, priority: 1 },
{ optionId: OPT_REVEST_MICROCEMENTO, priority: 2 },
{ optionId: OPT_REVEST_MARMOL, priority: 3 }
],
display : { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
{
id : ATTR_M2_BANO,
name : { es: 'Superficie', en: 'Surface' },
description : { es: 'Superficie del baño en m²', en: 'Bathroom surface in m²' },
type : 'fixed',
dataType : 'number',
value : m2,
unit : { es: 'm²', en: 'sqm' },
display : { uiVisible: false, readonly: true }
} as FixedAttribute
],
visualConfig: {
strategy : 'static_image',
imageUrlTemplate : '{basePath}/bano/{at:sanitario}/{at:revest_bano}/view.jpg',
basePath : '/renders/vivienda',
globalAttrDependencies : [ATTR_CALIDAD],
sectionAttrDependencies: [ATTR_SANITARIO, ATTR_REVEST_BANO]
},
availability: { mode: 'required' }
});
const seccionAseo = (): Section => ({
id : SEC_ASEO,
name : { es: 'Aseo', en: 'Toilet' },
description : { es: 'Aseo de cortesía', en: 'Guest toilet' },
attrs : [
{
id : ATTR_SANITARIO,
name : { es: 'Sanitarios', en: 'Sanitary ware' },
description : { es: 'Color de los sanitarios', en: 'Sanitary ware color' },
type : 'dynamic',
dataType : 'reference',
defaultValue: OPT_SANIT_BLANCO,
options : [
{ optionId: OPT_SANIT_BLANCO, priority: 1 },
{ optionId: OPT_SANIT_NEGRO, priority: 2 }
],
display : { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
{
id : ATTR_REVEST_BANO,
name : { es: 'Revestimiento', en: 'Cladding' },
description : { es: 'Revestimiento', en: 'Cladding' },
type : 'dynamic',
dataType : 'reference',
defaultValue: OPT_REVEST_CERAMICA,
options : [
{ optionId: OPT_REVEST_CERAMICA, priority: 1 },
{ optionId: OPT_REVEST_MICROCEMENTO, priority: 2 }
],
display : { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute
],
visualConfig: {
strategy : 'static_image',
imageUrlTemplate : '{basePath}/aseo/{at:sanitario}/{at:revest_bano}/view.jpg',
basePath : '/renders/vivienda',
globalAttrDependencies : [ATTR_CALIDAD],
sectionAttrDependencies: [ATTR_SANITARIO, ATTR_REVEST_BANO]
},
availability: { mode: 'required' }
});
const seccionDormitorio = (id: SectionID, nombre: string, m2: number): Section => ({
id,
name : { es: nombre, en: nombre.replace('Dormitorio', 'Bedroom') },
description : { es: `${nombre} principal`, en: `Main ${nombre.replace('Dormitorio', 'Bedroom')}` },
attrs : [
attrSuelo(),
attrPared(),
{
id : ATTR_M2_DORM,
name : { es: 'Superficie', en: 'Surface' },
description : { es: 'Superficie del dormitorio en m²', en: 'Bedroom surface in m²' },
type : 'fixed',
dataType : 'number',
value : m2,
unit : { es: 'm²', en: 'sqm' },
display : { uiVisible: false, readonly: true }
} as FixedAttribute
],
visualConfig: {
strategy : 'static_image',
imageUrlTemplate : '{basePath}/dormitorio/{at:suelo}/{at:pared}/view.jpg',
basePath : '/renders/vivienda',
globalAttrDependencies : [ATTR_CALIDAD],
sectionAttrDependencies: [ATTR_SUELO, ATTR_PARED]
},
availability: { mode: 'required' }
});
const seccionCocina = (): Section => ({
id : SEC_COCINA,
name : { es: 'Cocina', en: 'Kitchen' },
description : { es: 'Cocina equipada', en: 'Equipped kitchen' },
attrs : [
{
id : ATTR_ENCIMERA,
name : { es: 'Encimera', en: 'Countertop' },
description : { es: 'Material de la encimera', en: 'Countertop material' },
type : 'dynamic',
dataType : 'reference',
defaultValue: OPT_ENCIMERA_GRANITO,
options : [
{ optionId: OPT_ENCIMERA_GRANITO, priority: 1 },
{ optionId: OPT_ENCIMERA_SILESTONE, priority: 2 },
{ optionId: OPT_ENCIMERA_MADERA, priority: 3 }
],
display : { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
{
id : ATTR_MUEBLE_COC,
name : { es: 'Muebles', en: 'Cabinets' },
description : { es: 'Muebles de cocina', en: 'Kitchen cabinets' },
type : 'dynamic',
dataType : 'reference',
defaultValue: OPT_MUEBLE_BLANCO,
options : [
{ optionId: OPT_MUEBLE_BLANCO, priority: 1 },
{ optionId: OPT_MUEBLE_GRIS, priority: 2 },
{ optionId: OPT_MUEBLE_MADERA, priority: 3 }
],
display : { uiVisible: true, affectsVisual: true, affectsPrice: true }
} as DynamicAttribute,
attrSuelo(),
{
id : ATTR_M2_COCINA,
name : { es: 'Superficie', en: 'Surface' },
description : { es: 'Superficie de la cocina en m²', en: 'Kitchen surface in m²' },
type : 'fixed',
dataType : 'number',
value : 10,
unit : { es: 'm²', en: 'sqm' },
display : { uiVisible: false, readonly: true }
} as FixedAttribute
],
visualConfig: {
strategy : 'static_image',
imageUrlTemplate : '{basePath}/cocina/{at:encimera}/{at:mueble_cocina}/view.jpg',
basePath : '/renders/vivienda',
globalAttrDependencies : [ATTR_CALIDAD],
sectionAttrDependencies: [ATTR_ENCIMERA, ATTR_MUEBLE_COC]
},
availability: { mode: 'required' }
});
// ============================================================================
// ATRIBUTO GLOBAL - CALIDAD
// ============================================================================
const attrCalidadGlobal: DynamicAttribute = {
id : ATTR_CALIDAD,
name : { es: 'Calidad de acabados', en: 'Finish quality' },
description : { es: 'Nivel de calidad general de los acabados', en: 'General finish quality level' },
type : 'dynamic',
dataType : 'reference',
defaultValue: OPT_CALIDAD_ESTANDAR,
options : [
{ optionId: OPT_CALIDAD_ESTANDAR, priority: 1 },
{ optionId: OPT_CALIDAD_PREMIUM, priority: 2 },
{ optionId: OPT_CALIDAD_LUJO, priority: 3 }
],
isController: true,
display : { uiVisible: true, affectsVisual: true, affectsPrice: true }
};
// ============================================================================
// OBJETOS - APARTAMENTO Y DÚPLEX
// ============================================================================
export const APARTAMENTO: ConfigurableObject = {
id : OBJ_APARTAMENTO,
name : { es: 'Apartamento', en: 'Apartment' },
description : { es: 'Apartamento de 1 dormitorio (55 m²)', en: '1 bedroom apartment (55 sqm)' },
attributes : [attrCalidadGlobal],
sections : {
[SEC_SALON] : seccionSalon(),
[SEC_BANO] : seccionBano(6),
[SEC_DORMITORIO1]: seccionDormitorio(SEC_DORMITORIO1, 'Dormitorio', 14),
[SEC_COCINA] : seccionCocina()
},
sectionOrder: [SEC_SALON, SEC_COCINA, SEC_DORMITORIO1, SEC_BANO],
category : 'residencial'
};
export const DUPLEX: ConfigurableObject = {
id : OBJ_DUPLEX,
name : { es: 'Dúplex', en: 'Duplex' },
description : { es: 'Dúplex de 2 dormitorios (90 m²)', en: '2 bedroom duplex (90 sqm)' },
attributes : [attrCalidadGlobal],
sections : {
[SEC_SALON] : seccionSalon(),
[SEC_BANO] : seccionBano(8),
[SEC_ASEO] : seccionAseo(),
[SEC_DORMITORIO1]: seccionDormitorio(SEC_DORMITORIO1, 'Dormitorio principal', 18),
[SEC_DORMITORIO2]: seccionDormitorio(SEC_DORMITORIO2, 'Dormitorio secundario', 12)
},
sectionOrder: [SEC_SALON, SEC_DORMITORIO1, SEC_DORMITORIO2, SEC_BANO, SEC_ASEO],
category : 'residencial'
};
// ============================================================================
// REGLAS DE VALIDACIÓN
// ============================================================================
export const RULES: Record<RuleID, ValidationRule> = {
// Lujo requiere mármol en baño
[RULE_LUJO_MARMOL]: {
id : RULE_LUJO_MARMOL,
name : { es: 'Lujo requiere mármol', en: 'Luxury requires marble' },
condition : { '==': [{ var: 'attributes.at_calidad' }, OPT_CALIDAD_LUJO] },
action : {
type : 'require',
targetAttr: ATTR_REVEST_BANO,
values : [OPT_REVEST_MARMOL]
},
priority : 10,
bidirectional: false,
affects : [ATTR_CALIDAD, ATTR_REVEST_BANO],
severity : 'warning',
message : { es: 'El acabado de lujo recomienda mármol en el baño', en: 'Luxury finish recommends marble in bathroom' }
},
// Estándar no permite lujo
[RULE_ESTANDAR_NO_LUJO]: {
id : RULE_ESTANDAR_NO_LUJO,
name : { es: 'Estándar sin lujo', en: 'Standard without luxury' },
condition : { '==': [{ var: 'attributes.at_calidad' }, OPT_CALIDAD_ESTANDAR] },
action : {
type : 'forbid',
targetAttr: ATTR_REVEST_BANO,
values : [OPT_REVEST_MARMOL]
},
priority : 9,
bidirectional: false,
affects : [ATTR_CALIDAD, ATTR_REVEST_BANO],
severity : 'error',
message : { es: 'El revestimiento de mármol no está disponible en calidad estándar', en: 'Marble cladding is not available in standard quality' }
},
// Microcemento suelo → microcemento baño (consistencia estética)
[RULE_MICRO_CONSISTENTE]: {
id : RULE_MICRO_CONSISTENTE,
name : { es: 'Microcemento consistente', en: 'Consistent microcement' },
condition : { '==': [{ var: 'attributes.at_suelo' }, OPT_SUELO_MICROCEMENTO] },
action : {
type : 'allow',
targetAttr: ATTR_REVEST_BANO,
values : [OPT_REVEST_MICROCEMENTO, OPT_REVEST_CERAMICA]
},
priority : 5,
bidirectional: false,
affects : [ATTR_SUELO, ATTR_REVEST_BANO],
severity : 'warning',
message : { es: 'Con microcemento en suelo se recomienda microcemento o cerámica en baño', en: 'With microcement floor, microcement or ceramic is recommended in bathroom' }
},
// Encimera de madera no compatible con baño negro
[RULE_MADERA_NO_BANO]: {
id : RULE_MADERA_NO_BANO,
name : { es: 'Madera sin negro', en: 'Wood without black' },
condition : { '==': [{ var: 'attributes.at_encimera' }, OPT_ENCIMERA_MADERA] },
action : {
type : 'forbid',
targetAttr: ATTR_SANITARIO,
values : [OPT_SANIT_NEGRO]
},
priority : 7,
bidirectional: false,
affects : [ATTR_ENCIMERA, ATTR_SANITARIO],
severity : 'warning',
message : { es: 'La encimera de madera no combina con sanitarios negros', en: 'Wood countertop does not pair well with black sanitary ware' }
},
// Sanitarios negros requieren calidad premium o lujo
// Regla 1: sanitario negro → requiere premium o superior
[RULE_NEGRO_PREMIUM]: {
id : RULE_NEGRO_PREMIUM,
name : { es: 'Negro requiere premium', en: 'Black requires premium' },
condition : { '==': [{ var: 'attributes.at_sanitario' }, OPT_SANIT_NEGRO] },
action : {
type : 'forbid',
targetAttr: ATTR_CALIDAD,
values : [OPT_CALIDAD_ESTANDAR]
},
priority : 8,
bidirectional: false, // ✅ false
affects : [ATTR_SANITARIO, ATTR_CALIDAD],
severity : 'error',
message : { es: 'Los sanitarios en negro requieren calidad premium o superior', en: 'Black sanitary ware requires premium quality or higher' }
},
[RULE_ESTANDAR_NO_NEGRO]: {
id : RULE_ESTANDAR_NO_NEGRO,
name : { es: 'Estándar prohíbe negro', en: 'Standard forbids black' },
condition : { '==': [{ var: 'attributes.at_calidad' }, OPT_CALIDAD_ESTANDAR] },
action : {
type : 'forbid',
targetAttr: ATTR_SANITARIO,
values : [OPT_SANIT_NEGRO]
},
priority : 8,
bidirectional: false, // ✅ false
affects : [ATTR_CALIDAD, ATTR_SANITARIO],
severity : 'error',
message : { es: 'La calidad estándar no permite sanitarios en negro', en: 'Standard quality does not allow black sanitary ware' }
},
};
// ============================================================================
// CATÁLOGO COMPLETO
// ============================================================================
export const CATALOGO_VIVIENDAS: ConfigurationCatalog = {
name : { es: 'Catálogo de Viviendas', en: 'Housing Catalog' },
description : { es: 'Configurador de acabados para viviendas de obra nueva', en: 'Finish configurator for new build homes' },
supportedLocales: ['es', 'en'],
defaultLocale : 'es',
options : OPTIONS,
objects : {
[OBJ_APARTAMENTO]: APARTAMENTO,
[OBJ_DUPLEX] : DUPLEX
},
rules : RULES
};
// ============================================================================
// EXPORTS DE IDs (para uso en tests)
// ============================================================================
export const IDS_TEST = {
// Objetos
OBJ_APARTAMENTO,
OBJ_DUPLEX,
// Secciones
SEC_SALON,
SEC_BANO,
SEC_ASEO,
SEC_DORMITORIO1,
SEC_DORMITORIO2,
SEC_COCINA,
// Atributos
ATTR_CALIDAD,
ATTR_SUELO,
ATTR_PARED,
ATTR_PUERTA,
ATTR_ENCIMERA,
ATTR_MUEBLE_COC,
ATTR_SANITARIO,
ATTR_REVEST_BANO,
ATTR_M2_SALON,
ATTR_M2_BANO,
ATTR_M2_DORM,
ATTR_M2_COCINA,
// Opciones
OPT_CALIDAD_ESTANDAR,
OPT_CALIDAD_PREMIUM,
OPT_CALIDAD_LUJO,
OPT_SUELO_PARQUET,
OPT_SUELO_PORCELANICO,
OPT_SUELO_MICROCEMENTO,
OPT_SUELO_CERAMICA,
OPT_PARED_BLANCO,
OPT_PARED_GRIS,
OPT_PARED_PIEDRA,
OPT_PUERTA_ROBLE,
OPT_PUERTA_LACADA,
OPT_PUERTA_CRISTAL,
OPT_ENCIMERA_GRANITO,
OPT_ENCIMERA_SILESTONE,
OPT_ENCIMERA_MADERA,
OPT_MUEBLE_BLANCO,
OPT_MUEBLE_GRIS,
OPT_MUEBLE_MADERA,
OPT_SANIT_BLANCO,
OPT_SANIT_NEGRO,
OPT_REVEST_MARMOL,
OPT_REVEST_MICROCEMENTO,
OPT_REVEST_CERAMICA,
// Reglas
RULE_LUJO_MARMOL,
RULE_ESTANDAR_NO_LUJO,
RULE_MICRO_CONSISTENTE,
RULE_MADERA_NO_BANO,
RULE_NEGRO_PREMIUM,
RULE_ESTANDAR_NO_NEGRO,
};

@ -0,0 +1,508 @@
/**
* ============================================================================
* CATÁLOGO DE VIVIENDAS - TESTS
* ============================================================================
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { RuleEngine } from '../engine/rule-engine';
import {
CATALOGO_VIVIENDAS,
APARTAMENTO,
DUPLEX,
RULES,
IDS_TEST
} from '../tests/fixtures/housing-catalog.fixture';
const {
// Objetos
OBJ_APARTAMENTO, OBJ_DUPLEX,
// Secciones
SEC_SALON, SEC_BANO, SEC_ASEO, SEC_DORMITORIO1, SEC_DORMITORIO2, SEC_COCINA,
// Atributos
ATTR_CALIDAD, ATTR_SUELO, ATTR_SANITARIO, ATTR_REVEST_BANO,
ATTR_ENCIMERA, ATTR_PUERTA, ATTR_PARED,
// Opciones calidad
OPT_CALIDAD_ESTANDAR, OPT_CALIDAD_PREMIUM, OPT_CALIDAD_LUJO,
// Opciones suelo
OPT_SUELO_CERAMICA, OPT_SUELO_PARQUET, OPT_SUELO_MICROCEMENTO,
// Opciones baño
OPT_SANIT_BLANCO, OPT_SANIT_NEGRO,
OPT_REVEST_CERAMICA, OPT_REVEST_MICROCEMENTO, OPT_REVEST_MARMOL,
// Opciones cocina
OPT_ENCIMERA_GRANITO, OPT_ENCIMERA_MADERA,
// Opciones pared/puerta
OPT_PARED_BLANCO, OPT_PUERTA_LACADA
} = IDS_TEST;
// ============================================================================
// SETUP
// ============================================================================
let engine: RuleEngine;
const rules = Object.values(RULES);
beforeEach(() => {
engine = new RuleEngine();
});
// ============================================================================
// CATÁLOGO - ESTRUCTURA
// ============================================================================
describe('Catálogo - Estructura', () => {
it('tiene nombre y descripción i18n', () => {
expect(CATALOGO_VIVIENDAS.name).toHaveProperty('es');
expect(CATALOGO_VIVIENDAS.name).toHaveProperty('en');
});
it('contiene los 2 modelos de vivienda', () => {
expect(Object.keys(CATALOGO_VIVIENDAS.objects)).toHaveLength(2);
expect(CATALOGO_VIVIENDAS.objects[OBJ_APARTAMENTO]).toBeDefined();
expect(CATALOGO_VIVIENDAS.objects[OBJ_DUPLEX]).toBeDefined();
});
it('contiene todas las opciones', () => {
expect(Object.keys(CATALOGO_VIVIENDAS.options).length).toBeGreaterThan(0);
});
it('contiene las reglas de validación', () => {
expect(CATALOGO_VIVIENDAS.rules).toBeDefined();
expect(Object.keys(CATALOGO_VIVIENDAS.rules!).length).toBeGreaterThan(0);
});
it('todas las opciones referenciadas existen en el catálogo', () => {
for (const obj of Object.values(CATALOGO_VIVIENDAS.objects)) {
for (const section of Object.values(obj.sections)) {
for (const attr of section.attrs) {
if (attr.type === 'dynamic') {
for (const opt of attr.options) {
expect(
CATALOGO_VIVIENDAS.options[opt.optionId],
`Opción ${opt.optionId} no encontrada en catálogo`
).toBeDefined();
}
}
}
}
}
});
});
// ============================================================================
// APARTAMENTO - ESTRUCTURA
// ============================================================================
describe('Apartamento - Estructura', () => {
it('tiene 4 secciones: salón, baño, dormitorio y cocina', () => {
const secciones = Object.keys(APARTAMENTO.sections);
expect(secciones).toHaveLength(4);
expect(APARTAMENTO.sections[SEC_SALON]).toBeDefined();
expect(APARTAMENTO.sections[SEC_BANO]).toBeDefined();
expect(APARTAMENTO.sections[SEC_DORMITORIO1]).toBeDefined();
expect(APARTAMENTO.sections[SEC_COCINA]).toBeDefined();
});
it('NO tiene aseo ni segundo dormitorio', () => {
expect(APARTAMENTO.sections[SEC_ASEO]).toBeUndefined();
expect(APARTAMENTO.sections[SEC_DORMITORIO2]).toBeUndefined();
});
it('tiene 1 atributo global: calidad', () => {
expect(APARTAMENTO.attributes).toHaveLength(1);
expect(APARTAMENTO.attributes[0].id).toBe(ATTR_CALIDAD);
});
it('el orden de secciones es correcto', () => {
expect(APARTAMENTO.sectionOrder).toEqual([
SEC_SALON, SEC_COCINA, SEC_DORMITORIO1, SEC_BANO
]);
});
it('el baño tiene 6 m²', () => {
const bano = APARTAMENTO.sections[SEC_BANO];
const m2Attr = bano.attrs.find(a => a.type === 'fixed');
expect((m2Attr as any)?.value).toBe(6);
});
});
// ============================================================================
// DÚPLEX - ESTRUCTURA
// ============================================================================
describe('Dúplex - Estructura', () => {
it('tiene 5 secciones: salón, baño, aseo y 2 dormitorios', () => {
const secciones = Object.keys(DUPLEX.sections);
expect(secciones).toHaveLength(5);
expect(DUPLEX.sections[SEC_SALON]).toBeDefined();
expect(DUPLEX.sections[SEC_BANO]).toBeDefined();
expect(DUPLEX.sections[SEC_ASEO]).toBeDefined();
expect(DUPLEX.sections[SEC_DORMITORIO1]).toBeDefined();
expect(DUPLEX.sections[SEC_DORMITORIO2]).toBeDefined();
});
it('NO tiene cocina', () => {
expect(DUPLEX.sections[SEC_COCINA]).toBeUndefined();
});
it('el baño principal tiene más m² que el del apartamento', () => {
const banoDuplex = DUPLEX.sections[SEC_BANO];
const banoApartamento = APARTAMENTO.sections[SEC_BANO];
const m2Duplex = banoDuplex.attrs.find(a => a.type === 'fixed') as any;
const m2Apartamento = banoApartamento.attrs.find(a => a.type === 'fixed') as any;
expect(m2Duplex.value).toBeGreaterThan(m2Apartamento.value);
});
it('el dormitorio principal es más grande que el secundario', () => {
const dorm1 = DUPLEX.sections[SEC_DORMITORIO1];
const dorm2 = DUPLEX.sections[SEC_DORMITORIO2];
const m2Dorm1 = dorm1.attrs.find(a => a.type === 'fixed') as any;
const m2Dorm2 = dorm2.attrs.find(a => a.type === 'fixed') as any;
expect(m2Dorm1.value).toBeGreaterThan(m2Dorm2.value);
});
it('el aseo tiene menos opciones de revestimiento que el baño', () => {
const bano = DUPLEX.sections[SEC_BANO];
const aseo = DUPLEX.sections[SEC_ASEO];
const revestBano = bano.attrs.find(a => a.id === ATTR_REVEST_BANO) as any;
const revestAseo = aseo.attrs.find(a => a.id === ATTR_REVEST_BANO) as any;
expect(revestAseo.options.length).toBeLessThan(revestBano.options.length);
});
it('el orden de secciones es correcto', () => {
expect(DUPLEX.sectionOrder).toEqual([
SEC_SALON, SEC_DORMITORIO1, SEC_DORMITORIO2, SEC_BANO, SEC_ASEO
]);
});
});
// ============================================================================
// SECCIONES - ATRIBUTOS
// ============================================================================
describe('Secciones - Atributos', () => {
it('el salón tiene suelo, pared, puerta y m²', () => {
const salon = APARTAMENTO.sections[SEC_SALON];
const ids = salon.attrs.map(a => a.id);
expect(ids).toContain(ATTR_SUELO);
expect(ids).toContain(ATTR_PARED);
expect(ids).toContain(ATTR_PUERTA);
});
it('el salón tiene visual config con template de imagen', () => {
const salon = APARTAMENTO.sections[SEC_SALON];
expect(salon.visualConfig.imageUrlTemplate).toBeDefined();
expect(salon.visualConfig.imageUrlTemplate).toContain('{at:suelo}');
expect(salon.visualConfig.imageUrlTemplate).toContain('{at:pared}');
});
it('la cocina tiene encimera, muebles y suelo', () => {
const cocina = APARTAMENTO.sections[SEC_COCINA];
const ids = cocina.attrs.map(a => a.id);
expect(ids).toContain(ATTR_ENCIMERA);
expect(ids).toContain(ATTR_SUELO);
});
it('el baño tiene sanitario y revestimiento', () => {
const bano = APARTAMENTO.sections[SEC_BANO];
const ids = bano.attrs.map(a => a.id);
expect(ids).toContain(ATTR_SANITARIO);
expect(ids).toContain(ATTR_REVEST_BANO);
});
it('los atributos readonly no son visibles en UI', () => {
for (const obj of [APARTAMENTO, DUPLEX]) {
for (const section of Object.values(obj.sections)) {
for (const attr of section.attrs) {
if (attr.display.readonly) {
expect(attr.display.uiVisible).toBeFalsy();
}
}
}
}
});
it('todos los atributos dinámicos tienen defaultValue', () => {
for (const obj of [APARTAMENTO, DUPLEX]) {
for (const section of Object.values(obj.sections)) {
for (const attr of section.attrs) {
if (attr.type === 'dynamic') {
expect((attr as any).defaultValue,
`Atributo ${attr.id} en sección ${section.id} sin defaultValue`
).toBeDefined();
}
}
}
}
});
});
// ============================================================================
// REGLAS - CALIDAD ESTÁNDAR
// ============================================================================
describe('Reglas - Calidad Estándar', () => {
it('prohíbe mármol en baño con calidad estándar', () => {
const state = {
[ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR,
[ATTR_REVEST_BANO]: OPT_REVEST_MARMOL // prohibido
};
expect(engine.isValid(rules, state)).toBe(false);
});
it('permite cerámica y microcemento con calidad estándar', () => {
expect(engine.isValueAllowed(
ATTR_REVEST_BANO, OPT_REVEST_CERAMICA, rules,
{ [ATTR_CALIDAD]: OPT_CALIDAD_ESTANDAR }
)).toBe(true);
expect(engine.isValueAllowed(
ATTR_REVEST_BANO, OPT_REVEST_MICROCEMENTO, rules,
{ [ATTR_CALIDAD]: OPT_CALIDAD_ESTANDAR }
)).toBe(true);
});
it('prohíbe sanitarios negros con calidad estándar', () => {
const state = {
[ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR,
[ATTR_SANITARIO]: OPT_SANIT_NEGRO
};
expect(engine.isValid(rules, state)).toBe(false);
});
});
// ============================================================================
// REGLAS - CALIDAD PREMIUM
// ============================================================================
describe('Reglas - Calidad Premium', () => {
it('permite mármol con calidad premium', () => {
expect(engine.isValueAllowed(
ATTR_REVEST_BANO, OPT_REVEST_MARMOL, rules,
{ [ATTR_CALIDAD]: OPT_CALIDAD_PREMIUM }
)).toBe(true);
});
it('permite sanitarios negros con calidad premium', () => {
const state = {
[ATTR_CALIDAD] : OPT_CALIDAD_PREMIUM,
[ATTR_SANITARIO]: OPT_SANIT_NEGRO
};
expect(engine.isValid(rules, state)).toBe(true);
});
});
// ============================================================================
// REGLAS - CALIDAD LUJO
// ============================================================================
describe('Reglas - Calidad Lujo', () => {
it('recomienda mármol en baño (warning)', () => {
const state = { [ATTR_CALIDAD]: OPT_CALIDAD_LUJO };
const results = engine.getAttributeResults(rules, state);
const revest = results.get(ATTR_REVEST_BANO);
expect(revest?.required).toBe(true);
});
it('es válido aunque sin mármol (solo warning, no error)', () => {
const state = {
[ATTR_CALIDAD] : OPT_CALIDAD_LUJO,
[ATTR_REVEST_BANO]: OPT_REVEST_CERAMICA // no es mármol
};
// La regla de lujo es 'warning', no bloquea
expect(engine.isValid(rules, state)).toBe(true);
});
});
// ============================================================================
// REGLAS - MICROCEMENTO
// ============================================================================
describe('Reglas - Microcemento', () => {
it('con suelo microcemento, solo permite microcemento o cerámica en baño', () => {
const state = { [ATTR_SUELO]: OPT_SUELO_MICROCEMENTO };
expect(engine.isValueAllowed(
ATTR_REVEST_BANO, OPT_REVEST_MICROCEMENTO, rules, state
)).toBe(true);
expect(engine.isValueAllowed(
ATTR_REVEST_BANO, OPT_REVEST_CERAMICA, rules, state
)).toBe(true);
expect(engine.isValueAllowed(
ATTR_REVEST_BANO, OPT_REVEST_MARMOL, rules, state
)).toBe(false);
});
it('con otro suelo, cualquier revestimiento de baño está disponible', () => {
const state = { [ATTR_SUELO]: OPT_SUELO_PARQUET };
// Sin regla microcemento activada, mármol sería válido (si calidad lo permite)
const state2 = {
[ATTR_SUELO] : OPT_SUELO_PARQUET,
[ATTR_CALIDAD]: OPT_CALIDAD_PREMIUM
};
expect(engine.isValueAllowed(
ATTR_REVEST_BANO, OPT_REVEST_MARMOL, rules, state2
)).toBe(true);
});
});
// ============================================================================
// REGLAS - ENCIMERA MADERA
// ============================================================================
describe('Reglas - Encimera de madera', () => {
it('prohíbe sanitarios negros con encimera de madera', () => {
const state = {
[ATTR_ENCIMERA] : OPT_ENCIMERA_MADERA,
[ATTR_SANITARIO]: OPT_SANIT_NEGRO
};
const violations = engine.getViolations(rules, state);
const relevant = violations.filter(v =>
v.rule.affects.includes(ATTR_ENCIMERA)
);
expect(relevant.length).toBeGreaterThan(0);
});
it('permite sanitarios blancos con encimera de madera', () => {
const state = {
[ATTR_ENCIMERA] : OPT_ENCIMERA_MADERA,
[ATTR_SANITARIO]: OPT_SANIT_BLANCO
};
expect(engine.isValueAllowed(
ATTR_SANITARIO, OPT_SANIT_BLANCO, rules, state
)).toBe(true);
});
});
// ============================================================================
// REGLAS - BIDIRECCIONAL (negro ↔ calidad)
// ============================================================================
describe('Reglas - Bidireccional sanitarios negros', () => {
it('seleccionar negro con estándar genera violación', () => {
const state = {
[ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR,
[ATTR_SANITARIO]: OPT_SANIT_NEGRO
};
expect(engine.isValid(rules, state)).toBe(false);
});
it('seleccionar estándar con negro ya elegido también genera violación', () => {
// La regla es bidireccional: negro → prohíbe estándar
expect(engine.isValueAllowed(
ATTR_CALIDAD, OPT_CALIDAD_ESTANDAR, rules,
{ [ATTR_SANITARIO]: OPT_SANIT_NEGRO }
)).toBe(false);
});
});
// ============================================================================
// ESCENARIOS COMPLETOS
// ============================================================================
describe('Escenarios completos', () => {
it('Apartamento configuración básica válida', () => {
const state = {
[ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR,
[ATTR_SUELO] : OPT_SUELO_CERAMICA,
[ATTR_PARED] : OPT_PARED_BLANCO,
[ATTR_PUERTA] : OPT_PUERTA_LACADA,
[ATTR_ENCIMERA] : OPT_ENCIMERA_GRANITO,
[ATTR_SANITARIO] : OPT_SANIT_BLANCO,
[ATTR_REVEST_BANO]: OPT_REVEST_CERAMICA
};
// 🔍 DEBUG TEMPORAL
const violations = engine.getViolations(rules, state);
console.log('TRIGGERED:', engine.evaluateAll(rules, state)
.filter(r => r.triggered)
.map(r => ({ id: r.rule.id, action: r.action?.type, target: r.action?.targetAttr, values: r.action?.values }))
);
console.log('VIOLATIONS:', violations.map(v => ({ rule: v.rule.id, severity: v.severity })));
const allResults = engine.evaluateAll(rules, state);
const triggered = allResults.filter(r => r.triggered);
console.log('TRIGGERED RULES:', triggered.map(r => ({
id : r.rule.id,
action : r.action
})));
expect(engine.isValid(rules, state)).toBe(true);
expect(engine.getViolations(rules, state)).toHaveLength(0);
});
it('Apartamento configuración premium válida', () => {
const state = {
[ATTR_CALIDAD] : OPT_CALIDAD_PREMIUM,
[ATTR_SUELO] : OPT_SUELO_PARQUET,
[ATTR_SANITARIO] : OPT_SANIT_NEGRO,
[ATTR_REVEST_BANO]: OPT_REVEST_MARMOL,
[ATTR_ENCIMERA] : OPT_ENCIMERA_GRANITO
};
expect(engine.isValid(rules, state)).toBe(true);
expect(engine.getViolations(rules, state)).toHaveLength(0);
});
it('Dúplex configuración inválida (estándar + negro + mármol)', () => {
const state = {
[ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR,
[ATTR_SANITARIO] : OPT_SANIT_NEGRO, // error: requiere premium
[ATTR_REVEST_BANO]: OPT_REVEST_MARMOL // error: no disponible en estándar
};
const violations = engine.getViolations(rules, state);
expect(engine.isValid(rules, state)).toBe(false);
expect(violations.length).toBeGreaterThanOrEqual(2);
expect(violations.every(v => v.severity === 'error')).toBe(true);
});
it('Dúplex configuración de lujo válida', () => {
const state = {
[ATTR_CALIDAD] : OPT_CALIDAD_LUJO,
[ATTR_SUELO] : OPT_SUELO_PARQUET,
[ATTR_SANITARIO] : OPT_SANIT_NEGRO,
[ATTR_REVEST_BANO]: OPT_REVEST_MARMOL,
[ATTR_ENCIMERA] : OPT_ENCIMERA_GRANITO
};
expect(engine.isValid(rules, state)).toBe(true);
});
it('configuración vacía siempre es válida', () => {
expect(engine.isValid(rules, {})).toBe(true);
expect(engine.getViolations(rules, {})).toHaveLength(0);
});
it('las reglas de mayor prioridad se evalúan primero', () => {
const evaluated = engine.evaluateAll(rules, {
[ATTR_CALIDAD]: OPT_CALIDAD_ESTANDAR
});
const priorities = evaluated.map(r => r.rule.priority);
for (let i = 0; i < priorities.length - 1; i++) {
expect(priorities[i]).toBeGreaterThanOrEqual(priorities[i + 1]);
}
});
});

@ -0,0 +1,378 @@
/**
* ============================================================================
* ID PARSER - TESTS
* ============================================================================
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
isValidId,
hasPrefix,
parseId,
extractIdPart,
tryExtractIdPart,
IDS,
SafeIDS,
extractAllIdParts,
idsToRecord,
clearIdCache,
normalizeId
} from '@/utils/ids/id-parser';
// ============================================================================
// SETUP
// ============================================================================
beforeEach(() => {
clearIdCache();
});
// ============================================================================
// isValidId
// ============================================================================
describe('isValidId', () => {
it('retorna true para IDs válidos', () => {
expect(isValidId('at:suelo')).toBe(true);
expect(isValidId('sc:salon')).toBe(true);
expect(isValidId('op:roble')).toBe(true);
expect(isValidId('vw:front')).toBe(true);
});
it('retorna false para string vacío', () => {
expect(isValidId('')).toBe(false);
});
it('retorna false si no tiene separador ":"', () => {
expect(isValidId('atsuelo')).toBe(false);
});
it('retorna false si tiene más de un ":"', () => {
expect(isValidId('at:su:elo')).toBe(false);
});
it('retorna false si el prefijo está vacío', () => {
expect(isValidId(':suelo')).toBe(false);
});
it('retorna false si el valor está vacío', () => {
expect(isValidId('at:')).toBe(false);
});
});
// ============================================================================
// hasPrefix
// ============================================================================
describe('hasPrefix', () => {
it('retorna true si el prefijo coincide', () => {
expect(hasPrefix('at:suelo', 'at')).toBe(true);
expect(hasPrefix('sc:salon', 'sc')).toBe(true);
});
it('retorna false si el prefijo no coincide', () => {
expect(hasPrefix('at:suelo', 'sc')).toBe(false);
expect(hasPrefix('sc:salon', 'at')).toBe(false);
});
it('es case-sensitive', () => {
expect(hasPrefix('AT:suelo', 'at')).toBe(false);
expect(hasPrefix('at:suelo', 'AT')).toBe(false);
});
});
// ============================================================================
// parseId
// ============================================================================
describe('parseId', () => {
it('parsea un ID correctamente', () => {
const result = parseId('at:suelo');
expect(result).toEqual({
prefix: 'at',
value: 'suelo',
fullId: 'at:suelo'
});
});
it('parsea diferentes prefijos', () => {
expect(parseId('sc:salon').prefix).toBe('sc');
expect(parseId('op:roble').prefix).toBe('op');
expect(parseId('vw:front').value).toBe('front');
});
it('lanza error si el ID es inválido', () => {
expect(() => parseId('invalid')).toThrow();
expect(() => parseId('')).toThrow();
expect(() => parseId(':valor')).toThrow();
expect(() => parseId('prefijo:')).toThrow();
});
it('el error contiene mensaje i18n en español', () => {
try {
parseId('invalid');
} catch (e: any) {
const msg = JSON.parse(e.message);
expect(msg.es).toBeDefined();
expect(msg.en).toBeDefined();
expect(msg.es).toContain('invalid');
}
});
});
// ============================================================================
// extractIdPart
// ============================================================================
describe('extractIdPart', () => {
it('extrae la parte del valor correctamente', () => {
expect(extractIdPart('at:suelo')).toBe('suelo');
expect(extractIdPart('sc:salon')).toBe('salon');
expect(extractIdPart('op:roble')).toBe('roble');
});
it('valida el prefijo si se proporciona', () => {
expect(extractIdPart('at:suelo', 'at')).toBe('suelo');
});
it('lanza error si el prefijo no coincide', () => {
expect(() => extractIdPart('at:suelo', 'sc')).toThrow();
});
it('usa cache en llamadas repetidas', () => {
const spy = vi.spyOn(String.prototype, 'split');
extractIdPart('at:suelo');
extractIdPart('at:suelo'); // Segunda llamada - debe usar cache
// El split solo se llama en la primera llamada (en parseId)
// La segunda debe venir del cache
// @ts-ignore
const callCount = spy.mock.calls.filter(
args => (args[0] as unknown as string) === ':'
).length;
expect(callCount).toBeLessThan(4); // parseId llama split 2 veces, isValidId otras 2
spy.mockRestore();
});
it('diferentes prefijos tienen diferentes entradas en cache', () => {
expect(extractIdPart('at:suelo', 'at')).toBe('suelo');
expect(() => extractIdPart('at:suelo', 'sc')).toThrow();
});
});
// ============================================================================
// tryExtractIdPart
// ============================================================================
describe('tryExtractIdPart', () => {
it('retorna el valor si el ID es válido', () => {
expect(tryExtractIdPart('at:suelo')).toBe('suelo');
});
it('retorna undefined si el ID es inválido', () => {
expect(tryExtractIdPart('invalid' as any)).toBeUndefined();
});
it('retorna undefined si el prefijo no coincide', () => {
expect(tryExtractIdPart('at:suelo', 'sc')).toBeUndefined();
});
it('no lanza excepciones', () => {
expect(() => tryExtractIdPart('invalido' as any)).not.toThrow();
expect(() => tryExtractIdPart('' as any)).not.toThrow();
});
});
// ============================================================================
// IDS builders
// ============================================================================
describe('IDS', () => {
describe('IDS.attr', () => {
it('crea un AttrID válido', () => {
expect(IDS.attr('suelo')).toBe('at:suelo');
expect(IDS.attr('pared')).toBe('at:pared');
});
it('lanza error con string vacío', () => {
expect(() => IDS.attr('')).toThrow();
});
it('lanza error si contiene ":"', () => {
expect(() => IDS.attr('su:elo')).toThrow();
});
});
describe('IDS.sec', () => {
it('crea un SectionID válido', () => {
expect(IDS.sec('salon')).toBe('sc:salon');
});
it('lanza error con valor inválido', () => {
expect(() => IDS.sec('')).toThrow();
expect(() => IDS.sec('sa:lon')).toThrow();
});
});
describe('IDS.opt', () => {
it('crea un OptionID válido', () => {
expect(IDS.opt('roble')).toBe('op:roble');
});
});
describe('IDS.view', () => {
it('crea un ViewID válido', () => {
expect(IDS.view('front')).toBe('vw:front');
});
});
describe('IDS.obj', () => {
it('crea un ObjectID válido', () => {
expect(IDS.obj('sofa')).toBe('ob:sofa');
});
});
describe('IDS.rule', () => {
it('crea un RuleID válido', () => {
expect(IDS.rule('no_mezclar')).toBe('rl:no_mezclar');
});
});
describe('IDS.hspot', () => {
it('crea un HotspotID válido', () => {
expect(IDS.hspot('punto1')).toBe('hs:punto1');
});
});
it('el error contiene mensaje i18n', () => {
try {
IDS.attr('');
} catch (e: any) {
const msg = JSON.parse(e.message);
expect(msg.es).toBeDefined();
expect(msg.en).toBeDefined();
}
});
});
// ============================================================================
// SafeIDS builders
// ============================================================================
describe('SafeIDS', () => {
it('retorna el ID si el valor es válido', () => {
expect(SafeIDS.attr('suelo')).toBe('at:suelo');
expect(SafeIDS.sec('salon')).toBe('sc:salon');
expect(SafeIDS.opt('roble')).toBe('op:roble');
expect(SafeIDS.view('front')).toBe('vw:front');
expect(SafeIDS.obj('sofa')).toBe('ob:sofa');
expect(SafeIDS.rule('regla')).toBe('rl:regla');
expect(SafeIDS.hspot('punto')).toBe('hs:punto');
});
it('retorna undefined si el valor es inválido', () => {
expect(SafeIDS.attr('')).toBeUndefined();
expect(SafeIDS.attr('su:elo')).toBeUndefined();
expect(SafeIDS.sec('')).toBeUndefined();
});
it('no lanza excepciones', () => {
expect(() => SafeIDS.attr('')).not.toThrow();
expect(() => SafeIDS.sec('sa:lon')).not.toThrow();
});
});
// ============================================================================
// extractAllIdParts
// ============================================================================
describe('extractAllIdParts', () => {
it('extrae los valores de un array de IDs', () => {
const result = extractAllIdParts(['at:suelo', 'at:pared', 'at:techo']);
expect(result).toEqual(['suelo', 'pared', 'techo']);
});
it('valida el prefijo de todos los IDs', () => {
expect(() =>
extractAllIdParts(['at:suelo', 'sc:salon'], 'at')
).toThrow();
});
it('retorna array vacío para input vacío', () => {
expect(extractAllIdParts([])).toEqual([]);
});
});
// ============================================================================
// idsToRecord
// ============================================================================
describe('idsToRecord', () => {
it('convierte array de IDs a Record', () => {
const attrs = {
'at:suelo': 'roble',
'at:pared': 'blanco'
} as Record<string, string>;
const result = idsToRecord(
['at:suelo', 'at:pared'],
(id) => attrs[id]
);
expect(result).toEqual({
suelo: 'roble',
pared: 'blanco'
});
});
it('retorna objeto vacío para input vacío', () => {
expect(idsToRecord([], () => 'val')).toEqual({});
});
});
// ============================================================================
// normalizeId
// ============================================================================
describe('normalizeId', () => {
it('normaliza a lowercase', () => {
expect(normalizeId('at:SUELO')).toBe('at:suelo');
});
it('reemplaza espacios con underscores', () => {
expect(normalizeId('at:suelo parquet')).toBe('at:suelo_parquet');
});
it('hace trim', () => {
expect(normalizeId('at: suelo ')).toBe('at:suelo');
});
it('valida el prefijo si se proporciona', () => {
expect(normalizeId('at:suelo', 'at')).toBe('at:suelo');
expect(() => normalizeId('at:suelo', 'sc')).toThrow();
});
it('lanza error para ID inválido', () => {
expect(() => normalizeId('invalido')).toThrow();
});
});
// ============================================================================
// clearIdCache
// ============================================================================
describe('clearIdCache', () => {
it('limpia el cache sin errores', () => {
extractIdPart('at:suelo');
expect(() => clearIdCache()).not.toThrow();
});
it('permite re-parsear IDs tras limpiar el cache', () => {
extractIdPart('at:suelo');
clearIdCache();
// Después del clear debe funcionar igual
expect(extractIdPart('at:suelo')).toBe('suelo');
});
});

@ -0,0 +1,589 @@
/**
* ============================================================================
* RULE ENGINE - TESTS
* ============================================================================
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { RuleEngine } from '../engine/rule-engine';
import type { ValidationRule } from '@/types';
import type { AttrID, OptionID } from '@/types';
import type { SelectionState } from '../engine/rule-engine';
// ============================================================================
// FIXTURES
// ============================================================================
// IDs
const ATTR_MATERIAL : AttrID = 'at:material';
const ATTR_ACABADO : AttrID = 'at:acabado';
const ATTR_COLOR : AttrID = 'at:color';
const OPT_ROBLE : OptionID = 'op:roble';
const OPT_PINO : OptionID = 'op:pino';
const OPT_HAYA : OptionID = 'op:haya';
const OPT_PREMIUM : OptionID = 'op:premium';
const OPT_BASIC : OptionID = 'op:basic';
const OPT_BLANCO : OptionID = 'op:blanco';
const OPT_NEGRO : OptionID = 'op:negro';
const OPT_NATURAL : OptionID = 'op:natural';
/**
* Regla: Si material es PINO → prohibir acabado PREMIUM
*/
const RULE_PINO_NO_PREMIUM: ValidationRule = {
id : 'rl:pino_no_premium',
name : { es: 'Pino sin premium', en: 'Pine without premium' },
condition : { '==': [{ var: 'attributes.at_material' }, OPT_PINO] },
action : {
type : 'forbid',
targetAttr: ATTR_ACABADO,
values : [OPT_PREMIUM]
},
priority : 10,
bidirectional: false,
affects : [ATTR_MATERIAL, ATTR_ACABADO],
severity : 'error',
message : { es: 'El pino no admite acabado premium', en: 'Pine does not support premium finish' }
};
/**
* Regla: Si material es ROBLE → solo permitir colores BLANCO y NATURAL
*/
const RULE_ROBLE_COLORES: ValidationRule = {
id : 'rl:roble_colores',
name : { es: 'Roble colores permitidos', en: 'Oak allowed colors' },
condition : { '==': [{ var: 'attributes.at_material' }, OPT_ROBLE] },
action : {
type : 'allow',
targetAttr: ATTR_COLOR,
values : [OPT_BLANCO, OPT_NATURAL]
},
priority : 5,
bidirectional: false,
affects : [ATTR_MATERIAL, ATTR_COLOR],
severity : 'error',
message : { es: 'Roble solo admite blanco o natural', en: 'Oak only allows white or natural' }
};
/**
* Regla: Si acabado es PREMIUM → requerir material (no puede ser vacío)
*/
const RULE_PREMIUM_REQUIRES_MATERIAL: ValidationRule = {
id : 'rl:premium_requires_material',
name : { es: 'Premium requiere material', en: 'Premium requires material' },
condition : { '==': [{ var: 'attributes.at_acabado' }, OPT_PREMIUM] },
action : {
type : 'require',
targetAttr: ATTR_MATERIAL,
values : []
},
priority : 8,
bidirectional: false,
affects : [ATTR_ACABADO, ATTR_MATERIAL],
severity : 'error',
message : { es: 'El acabado premium requiere seleccionar material', en: 'Premium finish requires material selection' }
};
/**
* Regla: Si material es HAYA → sugerir color NEGRO (warning)
*/
const RULE_HAYA_SUGIERE_NEGRO: ValidationRule = {
id : 'rl:haya_sugiere_negro',
name : { es: 'Haya sugiere negro', en: 'Beech suggests black' },
condition : { '==': [{ var: 'attributes.at_material' }, OPT_HAYA] },
action : {
type : 'suggest',
targetAttr: ATTR_COLOR,
values : [OPT_NEGRO]
},
priority : 1,
bidirectional: false,
affects : [ATTR_MATERIAL, ATTR_COLOR],
severity : 'info',
message : { es: 'Para haya se recomienda color negro', en: 'Black color recommended for beech' }
};
/**
* Regla bidireccional: PINO y PREMIUM son incompatibles
*/
const RULE_BIDIRECCIONAL: ValidationRule = {
id : 'rl:incompatibles',
name : { es: 'Incompatibles', en: 'Incompatible' },
condition : { '==': [{ var: 'attributes.at_material' }, OPT_PINO] },
action : {
type : 'forbid',
targetAttr: ATTR_ACABADO,
values : [OPT_PREMIUM]
},
priority : 10,
bidirectional: true,
affects : [ATTR_MATERIAL, ATTR_ACABADO],
severity : 'error',
message : { es: 'Pino y premium son incompatibles', en: 'Pine and premium are incompatible' }
};
const ALL_RULES = [
RULE_PINO_NO_PREMIUM,
RULE_ROBLE_COLORES,
RULE_PREMIUM_REQUIRES_MATERIAL,
RULE_HAYA_SUGIERE_NEGRO
];
// ============================================================================
// SETUP
// ============================================================================
let engine: RuleEngine;
beforeEach(() => {
engine = new RuleEngine();
});
// ============================================================================
// evaluateRule
// ============================================================================
describe('evaluateRule', () => {
it('retorna triggered=true si la condición se cumple', () => {
const state: SelectionState = {
[ATTR_MATERIAL]: OPT_PINO,
[ATTR_ACABADO] : OPT_BASIC
};
const result = engine.evaluateRule(RULE_PINO_NO_PREMIUM, state);
expect(result.triggered).toBe(true);
expect(result.action).toBeDefined();
expect(result.action?.type).toBe('forbid');
});
it('retorna triggered=false si la condición NO se cumple', () => {
const state: SelectionState = {
[ATTR_MATERIAL]: OPT_ROBLE
};
const result = engine.evaluateRule(RULE_PINO_NO_PREMIUM, state);
expect(result.triggered).toBe(false);
expect(result.action).toBeUndefined();
});
it('incluye la regla en el resultado', () => {
const state: SelectionState = { [ATTR_MATERIAL]: OPT_PINO };
const result = engine.evaluateRule(RULE_PINO_NO_PREMIUM, state);
expect(result.rule.id).toBe('rl:pino_no_premium');
});
});
// ============================================================================
// evaluateAll
// ============================================================================
describe('evaluateAll', () => {
it('evalúa todas las reglas y las ordena por prioridad', () => {
const state: SelectionState = {
[ATTR_MATERIAL]: OPT_PINO,
[ATTR_ACABADO] : OPT_PREMIUM
};
const results = engine.evaluateAll(ALL_RULES, state);
// Verificar que están ordenadas por prioridad descendente
for (let i = 0; i < results.length - 1; i++) {
expect(results[i].rule.priority).toBeGreaterThanOrEqual(
results[i + 1].rule.priority
);
}
});
it('retorna resultado para cada regla', () => {
const state: SelectionState = {};
const results = engine.evaluateAll(ALL_RULES, state);
expect(results.length).toBe(ALL_RULES.length);
});
it('detecta múltiples reglas activadas', () => {
const state: SelectionState = {
[ATTR_MATERIAL]: OPT_PINO,
[ATTR_ACABADO] : OPT_PREMIUM
};
const results = engine.evaluateAll(ALL_RULES, state);
const triggered = results.filter(r => r.triggered);
expect(triggered.length).toBeGreaterThan(1);
});
});
// ============================================================================
// getAttributeResults - forbid
// ============================================================================
describe('getAttributeResults - forbid', () => {
it('añade valor prohibido si la regla se activa', () => {
const state: SelectionState = {
[ATTR_MATERIAL]: OPT_PINO,
[ATTR_ACABADO] : OPT_PREMIUM // valor prohibido seleccionado
};
const results = engine.getAttributeResults([RULE_PINO_NO_PREMIUM], state);
const acabado = results.get(ATTR_ACABADO);
expect(acabado).toBeDefined();
expect(acabado?.forbiddenValues).toContain(OPT_PREMIUM);
});
it('genera violación si el valor actual está prohibido', () => {
const state: SelectionState = {
[ATTR_MATERIAL]: OPT_PINO,
[ATTR_ACABADO] : OPT_PREMIUM
};
const results = engine.getAttributeResults([RULE_PINO_NO_PREMIUM], state);
const acabado = results.get(ATTR_ACABADO);
expect(acabado?.violations.length).toBeGreaterThan(0);
expect(acabado?.violations[0].severity).toBe('error');
});
it('NO genera violación si el valor actual no está prohibido', () => {
const state: SelectionState = {
[ATTR_MATERIAL]: OPT_PINO,
[ATTR_ACABADO] : OPT_BASIC // no prohibido
};
const results = engine.getAttributeResults([RULE_PINO_NO_PREMIUM], state);
const acabado = results.get(ATTR_ACABADO);
expect(acabado?.violations.length).toBe(0);
});
it('no activa la regla si la condición no se cumple', () => {
const state: SelectionState = {
[ATTR_MATERIAL]: OPT_ROBLE // no es pino
};
const results = engine.getAttributeResults([RULE_PINO_NO_PREMIUM], state);
expect(results.has(ATTR_ACABADO)).toBe(false);
});
});
// ============================================================================
// getAttributeResults - allow
// ============================================================================
describe('getAttributeResults - allow', () => {
it('establece lista de valores permitidos', () => {
const state: SelectionState = {
[ATTR_MATERIAL]: OPT_ROBLE
};
const results = engine.getAttributeResults([RULE_ROBLE_COLORES], state);
const color = results.get(ATTR_COLOR);
expect(color?.allowedValues).toContain(OPT_BLANCO);
expect(color?.allowedValues).toContain(OPT_NATURAL);
expect(color?.allowedValues).not.toContain(OPT_NEGRO);
});
it('intersecta listas si hay múltiples reglas allow', () => {
const RULE_ALLOW_BLANCO_NEGRO: ValidationRule = {
...RULE_ROBLE_COLORES,
id : 'rl:allow2',
action: {
type : 'allow',
targetAttr: ATTR_COLOR,
values : [OPT_BLANCO, OPT_NEGRO]
}
};
const state: SelectionState = { [ATTR_MATERIAL]: OPT_ROBLE };
const results = engine.getAttributeResults(
[RULE_ROBLE_COLORES, RULE_ALLOW_BLANCO_NEGRO],
state
);
const color = results.get(ATTR_COLOR);
// Intersección: solo BLANCO está en ambas listas
expect(color?.allowedValues).toEqual([OPT_BLANCO]);
});
});
// ============================================================================
// getAttributeResults - require
// ============================================================================
describe('getAttributeResults - require', () => {
it('marca el atributo como requerido', () => {
const state: SelectionState = {
[ATTR_ACABADO]: OPT_PREMIUM
};
const results = engine.getAttributeResults([RULE_PREMIUM_REQUIRES_MATERIAL], state);
const material = results.get(ATTR_MATERIAL);
expect(material?.required).toBe(true);
});
it('genera violación si el atributo requerido está vacío', () => {
const state: SelectionState = {
[ATTR_ACABADO]: OPT_PREMIUM
// ATTR_MATERIAL no está seleccionado
};
const results = engine.getAttributeResults([RULE_PREMIUM_REQUIRES_MATERIAL], state);
const material = results.get(ATTR_MATERIAL);
expect(material?.violations.length).toBeGreaterThan(0);
});
it('NO genera violación si el atributo requerido tiene valor', () => {
const state: SelectionState = {
[ATTR_ACABADO] : OPT_PREMIUM,
[ATTR_MATERIAL]: OPT_ROBLE // está seleccionado
};
const results = engine.getAttributeResults([RULE_PREMIUM_REQUIRES_MATERIAL], state);
const material = results.get(ATTR_MATERIAL);
expect(material?.violations.length).toBe(0);
});
});
// ============================================================================
// isValueAllowed
// ============================================================================
describe('isValueAllowed', () => {
it('retorna true si no hay reglas para el atributo', () => {
const state: SelectionState = {};
expect(engine.isValueAllowed(ATTR_COLOR, OPT_NEGRO, [], state)).toBe(true);
});
it('retorna false si el valor está prohibido', () => {
const state: SelectionState = { [ATTR_MATERIAL]: OPT_PINO };
expect(
engine.isValueAllowed(ATTR_ACABADO, OPT_PREMIUM, [RULE_PINO_NO_PREMIUM], state)
).toBe(false);
});
it('retorna true si el valor no está prohibido', () => {
const state: SelectionState = { [ATTR_MATERIAL]: OPT_PINO };
expect(
engine.isValueAllowed(ATTR_ACABADO, OPT_BASIC, [RULE_PINO_NO_PREMIUM], state)
).toBe(true);
});
it('retorna false si hay allow list y el valor no está en ella', () => {
const state: SelectionState = { [ATTR_MATERIAL]: OPT_ROBLE };
expect(
engine.isValueAllowed(ATTR_COLOR, OPT_NEGRO, [RULE_ROBLE_COLORES], state)
).toBe(false);
});
it('retorna true si el valor está en la allow list', () => {
const state: SelectionState = { [ATTR_MATERIAL]: OPT_ROBLE };
expect(
engine.isValueAllowed(ATTR_COLOR, OPT_BLANCO, [RULE_ROBLE_COLORES], state)
).toBe(true);
});
});
// ============================================================================
// getViolations
// ============================================================================
describe('getViolations', () => {
it('retorna array vacío si no hay violaciones', () => {
const state: SelectionState = {
[ATTR_MATERIAL]: OPT_ROBLE,
[ATTR_ACABADO] : OPT_BASIC,
[ATTR_COLOR] : OPT_BLANCO
};
expect(engine.getViolations(ALL_RULES, state)).toEqual([]);
});
it('retorna violaciones cuando hay conflictos', () => {
const state: SelectionState = {
[ATTR_MATERIAL]: OPT_PINO,
[ATTR_ACABADO] : OPT_PREMIUM // prohibido para pino
};
const violations = engine.getViolations(ALL_RULES, state);
expect(violations.length).toBeGreaterThan(0);
});
it('ordena por severidad: error primero', () => {
const RULE_WARNING: ValidationRule = {
...RULE_PINO_NO_PREMIUM,
id : 'rl:warning_rule',
severity: 'warning',
action : {
type : 'forbid',
targetAttr: ATTR_COLOR,
values : [OPT_NEGRO]
}
};
const state: SelectionState = {
[ATTR_MATERIAL]: OPT_PINO,
[ATTR_ACABADO] : OPT_PREMIUM,
[ATTR_COLOR] : OPT_NEGRO
};
const violations = engine.getViolations(
[RULE_PINO_NO_PREMIUM, RULE_WARNING],
state
);
expect(violations[0].severity).toBe('error');
});
});
// ============================================================================
// isValid
// ============================================================================
describe('isValid', () => {
it('retorna true si no hay violaciones de error', () => {
const state: SelectionState = {
[ATTR_MATERIAL]: OPT_ROBLE,
[ATTR_ACABADO] : OPT_BASIC,
[ATTR_COLOR] : OPT_BLANCO
};
expect(engine.isValid(ALL_RULES, state)).toBe(true);
});
it('retorna false si hay violaciones de error', () => {
const state: SelectionState = {
[ATTR_MATERIAL]: OPT_PINO,
[ATTR_ACABADO] : OPT_PREMIUM
};
expect(engine.isValid(ALL_RULES, state)).toBe(false);
});
it('retorna true si solo hay warnings (no errors)', () => {
const RULE_ONLY_WARNING: ValidationRule = {
...RULE_PINO_NO_PREMIUM,
severity: 'warning'
};
const state: SelectionState = {
[ATTR_MATERIAL]: OPT_PINO,
[ATTR_ACABADO] : OPT_PREMIUM
};
expect(engine.isValid([RULE_ONLY_WARNING], state)).toBe(true);
});
});
// ============================================================================
// BIDIRECCIONAL
// ============================================================================
describe('Reglas bidireccionales', () => {
it('se activa con la condición directa (material=pino)', () => {
const state: SelectionState = { [ATTR_MATERIAL]: OPT_PINO };
const result = engine.evaluateRule(RULE_BIDIRECCIONAL, state);
expect(result.triggered).toBe(true);
});
it('se activa con la condición inversa (acabado=premium)', () => {
const state: SelectionState = { [ATTR_ACABADO]: OPT_PREMIUM };
const result = engine.evaluateRule(RULE_BIDIRECCIONAL, state);
expect(result.triggered).toBe(true);
});
it('NO se activa si ninguna condición se cumple', () => {
const state: SelectionState = {
[ATTR_MATERIAL]: OPT_ROBLE,
[ATTR_ACABADO] : OPT_BASIC
};
const result = engine.evaluateRule(RULE_BIDIRECCIONAL, state);
expect(result.triggered).toBe(false);
});
});
// ============================================================================
// CASOS COMPLEJOS
// ============================================================================
describe('Casos complejos', () => {
it('múltiples reglas sobre el mismo atributo', () => {
const RULE_HAYA_NO_PREMIUM: ValidationRule = {
...RULE_PINO_NO_PREMIUM,
id : 'rl:haya_no_premium',
condition: { '==': [{ var: 'attributes.at_material' }, OPT_HAYA] }
};
const state: SelectionState = {
[ATTR_MATERIAL]: OPT_HAYA,
[ATTR_ACABADO] : OPT_PREMIUM
};
expect(
engine.isValueAllowed(
ATTR_ACABADO,
OPT_PREMIUM,
[RULE_PINO_NO_PREMIUM, RULE_HAYA_NO_PREMIUM],
state
)
).toBe(false);
});
it('estado vacío no genera violaciones', () => {
const state: SelectionState = {};
expect(engine.getViolations(ALL_RULES, state)).toEqual([]);
expect(engine.isValid(ALL_RULES, state)).toBe(true);
});
it('regla con condición compleja AND', () => {
const RULE_COMPLEJA: ValidationRule = {
id : 'rl:compleja',
name : { es: 'Regla compleja', en: 'Complex rule' },
condition : {
and: [
{ '==': [{ var: 'attributes.at_material' }, OPT_ROBLE] },
{ '==': [{ var: 'attributes.at_acabado' }, OPT_PREMIUM] }
]
},
action : {
type : 'allow',
targetAttr: ATTR_COLOR,
values : [OPT_NATURAL]
},
priority : 10,
bidirectional: false,
affects : [ATTR_MATERIAL, ATTR_ACABADO, ATTR_COLOR],
severity : 'error',
message : { es: 'Solo natural para roble premium', en: 'Only natural for premium oak' }
};
// Ambas condiciones cumplidas
const stateTrue: SelectionState = {
[ATTR_MATERIAL]: OPT_ROBLE,
[ATTR_ACABADO] : OPT_PREMIUM
};
expect(engine.evaluateRule(RULE_COMPLEJA, stateTrue).triggered).toBe(true);
// Solo una condición cumplida
const stateFalse: SelectionState = {
[ATTR_MATERIAL]: OPT_ROBLE,
[ATTR_ACABADO] : OPT_BASIC
};
expect(engine.evaluateRule(RULE_COMPLEJA, stateFalse).triggered).toBe(false);
});
});

@ -0,0 +1,171 @@
/**
* ============================================================================
* TRANSLATION UTILS - TESTS
* ============================================================================
*/
import { describe, it, expect } from 'vitest';
import {
translate,
t,
hasTranslation,
getAvailableLocales,
isComplete,
getMissingLocales
} from '@/utils/i18n/translation.utils';
import type { I18nString } from '@/types';
// ============================================================================
// FIXTURES
// ============================================================================
const SIMPLE: I18nString = 'Texto simple';
const FULL: I18nString = {
es: 'Hola',
en: 'Hello',
de: 'Hallo',
fr: 'Bonjour',
it: 'Ciao',
pt: 'Olá',
ca: 'Hola',
eu: 'Kaixo',
gl: 'Ola'
};
const PARTIAL: I18nString = {
es: 'Hola',
en: 'Hello'
};
// ============================================================================
// translate
// ============================================================================
describe('translate', () => {
describe('con string simple', () => {
it('retorna el string directamente sin importar el locale', () => {
expect(translate(SIMPLE, 'es')).toBe('Texto simple');
expect(translate(SIMPLE, 'en')).toBe('Texto simple');
expect(translate(SIMPLE, 'de')).toBe('Texto simple');
});
});
describe('con objeto de traducciones', () => {
it('retorna la traducción del locale solicitado', () => {
expect(translate(FULL, 'es')).toBe('Hola');
expect(translate(FULL, 'en')).toBe('Hello');
expect(translate(FULL, 'de')).toBe('Hallo');
expect(translate(FULL, 'fr')).toBe('Bonjour');
});
it('hace fallback al locale por defecto si no existe la traducción', () => {
expect(translate(PARTIAL, 'de')).toBe('Hola'); // fallback a 'es'
expect(translate(PARTIAL, 'fr')).toBe('Hola');
});
it('usa el fallback personalizado si se proporciona', () => {
expect(translate(PARTIAL, 'de', 'en')).toBe('Hello'); // fallback a 'en'
});
it('retorna el primer valor disponible si no hay fallback', () => {
const onlyFr: I18nString = { es: 'Hola' };
expect(translate(onlyFr, 'en')).toBe('Hola');
});
it('retorna string vacío si no hay ningún valor', () => {
// Edge case: objeto con es vacío
const empty = { es: '' };
// translate retornará '' porque text[locale] es falsy y pasará al fallback
// y text[fallback].es también es '' y Object.values[0] es ''
expect(translate(empty as I18nString, 'en')).toBe('');
});
});
});
describe('t (alias de translate)', () => {
it('funciona igual que translate', () => {
expect(t(FULL, 'es')).toBe(translate(FULL, 'es'));
expect(t(SIMPLE, 'en')).toBe(translate(SIMPLE, 'en'));
});
});
// ============================================================================
// hasTranslation
// ============================================================================
describe('hasTranslation', () => {
it('retorna true para string simple en cualquier locale', () => {
expect(hasTranslation(SIMPLE, 'es')).toBe(true);
expect(hasTranslation(SIMPLE, 'zh' as any)).toBe(true);
});
it('retorna true si el locale existe en el objeto', () => {
expect(hasTranslation(PARTIAL, 'es')).toBe(true);
expect(hasTranslation(PARTIAL, 'en')).toBe(true);
});
it('retorna false si el locale no existe en el objeto', () => {
expect(hasTranslation(PARTIAL, 'de')).toBe(false);
expect(hasTranslation(PARTIAL, 'fr')).toBe(false);
});
});
// ============================================================================
// getAvailableLocales
// ============================================================================
describe('getAvailableLocales', () => {
it('retorna todos los locales soportados para string simple', () => {
const locales = getAvailableLocales(SIMPLE);
expect(locales).toContain('es');
expect(locales).toContain('en');
expect(locales).toContain('de');
expect(locales.length).toBeGreaterThan(0);
});
it('retorna los locales presentes en el objeto', () => {
const locales = getAvailableLocales(PARTIAL);
expect(locales).toEqual(['es', 'en']);
});
});
// ============================================================================
// isComplete
// ============================================================================
describe('isComplete', () => {
it('retorna true para string simple', () => {
expect(isComplete(SIMPLE)).toBe(true);
});
it('retorna true si tiene todos los locales soportados', () => {
expect(isComplete(FULL)).toBe(true);
});
it('retorna false si faltan locales', () => {
expect(isComplete(PARTIAL)).toBe(false);
});
});
// ============================================================================
// getMissingLocales
// ============================================================================
describe('getMissingLocales', () => {
it('retorna array vacío para string simple', () => {
expect(getMissingLocales(SIMPLE)).toEqual([]);
});
it('retorna array vacío si están todos los locales', () => {
expect(getMissingLocales(FULL)).toEqual([]);
});
it('retorna los locales que faltan', () => {
const missing = getMissingLocales(PARTIAL);
expect(missing).not.toContain('es');
expect(missing).not.toContain('en');
expect(missing).toContain('de');
expect(missing).toContain('fr');
});
});

@ -0,0 +1,20 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/**/*.d.ts']
}
},
resolve: {
alias: {
'@': resolve(__dirname, './src')
}
}
});
Loading…
Cancel
Save

Powered by TurnKey Linux.