parent
e81ecc8e14
commit
4c0e0a33f0
@ -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…
Reference in new issue