You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
vce/src/tests/rule-engine.test.ts

590 lines
20 KiB

/**
* ============================================================================
* 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);
});
});

Powered by TurnKey Linux.