diff --git a/package-lock.json b/package-lock.json index 6d23e0e..0a96bed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,16 @@ { - "name": "cpq-engine", - "version": "1.0.0", + "name": "visual-configuration-engine", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "cpq-engine", - "version": "1.0.0", - "license": "ISC", + "name": "visual-configuration-engine", + "version": "2.0.0", + "license": "EULA", "devDependencies": { "@types/node": "^25.2.3", "ts-node": "^10.9.2", - "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.0.18" } @@ -1202,19 +1201,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1318,16 +1304,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/rollup": { "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", @@ -1492,26 +1468,6 @@ } } }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 203e9ad..a9a2d67 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,13 @@ "typecheck": "tsc --noEmit", "dev": "tsc --watch" }, - "keywords": ["configuration", "visual", "typescript"], + "keywords": [ + "configuration", + "visual", + "typescript" + ], "author": "ACTIVE THING", "license": "EULA", - - "devDependencies": { "@types/node": "^25.2.3", "ts-node": "^10.9.2", @@ -22,4 +24,3 @@ "vitest": "^4.0.18" } } - diff --git a/src/engine/rule-engine.ts b/src/engine/rule-engine.ts new file mode 100644 index 0000000..be44ffd --- /dev/null +++ b/src/engine/rule-engine.ts @@ -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; + +/** + * 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 { + const results = new Map(); + 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 { + const normalized: Record = {}; + for (const [key, value] of Object.entries(state)) { + normalized[key.replace(':', '_')] = value; // 'at:calidad' → 'at_calidad' + } + return normalized; +} + +// ============================================================================ +// SINGLETON +// ============================================================================ + +export const ruleEngine = new RuleEngine(); diff --git a/src/tests/evaluator.test.ts b/src/tests/evaluator.test.ts new file mode 100644 index 0000000..25c3fa5 --- /dev/null +++ b/src/tests/evaluator.test.ts @@ -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); + }); +}); diff --git a/src/tests/fixtures/housing-catalog.fixture.ts b/src/tests/fixtures/housing-catalog.fixture.ts new file mode 100644 index 0000000..c0a9468 --- /dev/null +++ b/src/tests/fixtures/housing-catalog.fixture.ts @@ -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 = { + // 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 = { + // 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, +}; diff --git a/src/tests/housing-catalog.test.ts b/src/tests/housing-catalog.test.ts new file mode 100644 index 0000000..b1fd3bb --- /dev/null +++ b/src/tests/housing-catalog.test.ts @@ -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]); + } + }); +}); diff --git a/src/tests/id-parser.test.ts b/src/tests/id-parser.test.ts new file mode 100644 index 0000000..0d0a5f0 --- /dev/null +++ b/src/tests/id-parser.test.ts @@ -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; + + 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'); + }); +}); diff --git a/src/tests/rule-engine.test.ts b/src/tests/rule-engine.test.ts new file mode 100644 index 0000000..03128a7 --- /dev/null +++ b/src/tests/rule-engine.test.ts @@ -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); + }); +}); diff --git a/src/tests/translation.utils.test.ts b/src/tests/translation.utils.test.ts new file mode 100644 index 0000000..ef2b84d --- /dev/null +++ b/src/tests/translation.utils.test.ts @@ -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'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..6f732ae --- /dev/null +++ b/vitest.config.ts @@ -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') + } + } +});