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