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.
590 lines
20 KiB
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);
|
|
});
|
|
});
|