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.
509 lines
19 KiB
509 lines
19 KiB
/**
|
|
* ============================================================================
|
|
* 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]);
|
|
}
|
|
});
|
|
});
|