parent
c81ab33d66
commit
f0ba99b7dd
@ -0,0 +1,5 @@
|
||||
|
||||
|
||||
|
||||
export * from './messages.ts';
|
||||
|
||||
@ -0,0 +1,263 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* CONFIGURATION ENGINE
|
||||
* ============================================================================
|
||||
*
|
||||
* Orquestador principal — única API que consume la UI.
|
||||
* Gestiona múltiples objetos en paralelo con un objeto activo.
|
||||
*
|
||||
* Uso:
|
||||
* const engine = new ConfigurationEngine(catalog, logr);
|
||||
* engine.setActiveObject('ob:apartamento');
|
||||
* engine.select('at:suelo', 'op:parquet');
|
||||
* engine.getPrice();
|
||||
*/
|
||||
|
||||
import type { Logr } from '@/libs/logr';
|
||||
import type {
|
||||
ConfigurationCatalog,
|
||||
ObjectID,
|
||||
OptionID,
|
||||
AttrID,
|
||||
OptionSelection,
|
||||
SelectionMap,
|
||||
SectionID,
|
||||
ViewID
|
||||
} from '../types';
|
||||
|
||||
|
||||
import type { AttributeState } from './selection-state';
|
||||
import type { PricingResult } from './pricing';
|
||||
import type { ResolveResult, ApiResolveResult } from './template-resolver';
|
||||
import { SelectionState } from './selection-state';
|
||||
import { PricingEngine } from './pricing';
|
||||
import { TemplateResolver } from './template-resolver';
|
||||
import { ENGINE_CATEGORIES, SELECTION_ERRORS } from '../consts/messages';
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export type ChangeEventType = 'selection' | 'object';
|
||||
|
||||
export interface ChangeEvent {
|
||||
type : ChangeEventType;
|
||||
objectId: ObjectID;
|
||||
/** Solo presente cuando type === 'selection' */
|
||||
attrId? : AttrID;
|
||||
affectsVisual: boolean;
|
||||
affectsPrice : boolean;
|
||||
}
|
||||
|
||||
export type ChangeListener = (event: ChangeEvent) => void;
|
||||
|
||||
export interface SelectionOutcome {
|
||||
success : boolean;
|
||||
reason? : string;
|
||||
affectsVisual: boolean;
|
||||
affectsPrice : boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONFIGURATION ENGINE
|
||||
// ============================================================================
|
||||
|
||||
export class ConfigurationEngine {
|
||||
private readonly catalog : ConfigurationCatalog;
|
||||
private readonly logr : Logr;
|
||||
private readonly pricingEngine : PricingEngine;
|
||||
private readonly templateResolver: TemplateResolver;
|
||||
|
||||
/** SelectionState por objectId */
|
||||
private readonly states : Map<ObjectID, SelectionState> = new Map();
|
||||
private activeObjectId : ObjectID | null = null;
|
||||
private readonly listeners : Set<ChangeListener> = new Set();
|
||||
|
||||
constructor(catalog: ConfigurationCatalog, logr: Logr) {
|
||||
this.catalog = catalog;
|
||||
this.logr = logr;
|
||||
this.pricingEngine = new PricingEngine(logr);
|
||||
this.templateResolver = new TemplateResolver(logr);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Objeto activo
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Activa un objeto del catálogo.
|
||||
* Crea su SelectionState si no existe todavía.
|
||||
* Emite evento 'object'.
|
||||
*/
|
||||
setActiveObject(objectId: ObjectID): void {
|
||||
if (!this.catalog.objects[objectId]) {
|
||||
this.logr.error(
|
||||
ENGINE_CATEGORIES.SELECTION,
|
||||
SELECTION_ERRORS.OBJECT_NOT_FOUND(objectId),
|
||||
{ objectId }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.states.has(objectId)) {
|
||||
this.states.set(
|
||||
objectId,
|
||||
new SelectionState(objectId, this.catalog, this.logr)
|
||||
);
|
||||
}
|
||||
|
||||
this.activeObjectId = objectId;
|
||||
this.emit({ type: 'object', objectId, affectsVisual: true, affectsPrice: true });
|
||||
}
|
||||
|
||||
getActiveObjectId(): ObjectID | null {
|
||||
return this.activeObjectId;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Selección
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Selecciona una opción para un atributo del objeto activo.
|
||||
* Solo acepta valores permitidos por el RuleEngine — modelo preventivo.
|
||||
* Emite evento 'selection' si tiene éxito.
|
||||
*/
|
||||
select(attrId: AttrID, optionId: OptionID, quantity?: number): SelectionOutcome {
|
||||
const state = this.activeState();
|
||||
if (!state) return { success: false, reason: 'No active object', affectsVisual: false, affectsPrice: false };
|
||||
|
||||
const value: OptionSelection = { optionId, quantity };
|
||||
const result = state.select(attrId, value);
|
||||
|
||||
if (!result.success) {
|
||||
return { ...result, affectsVisual: false, affectsPrice: false };
|
||||
}
|
||||
|
||||
const { affectsVisual, affectsPrice } = this.getAttrDisplayFlags(attrId);
|
||||
|
||||
this.emit({
|
||||
type : 'selection',
|
||||
objectId: this.activeObjectId!,
|
||||
attrId,
|
||||
affectsVisual,
|
||||
affectsPrice,
|
||||
});
|
||||
|
||||
return { success: true, affectsVisual, affectsPrice };
|
||||
}
|
||||
|
||||
getValue(attrId: AttrID): OptionSelection | unknown {
|
||||
return this.activeState()?.getValue(attrId);
|
||||
}
|
||||
|
||||
getSelection(): SelectionMap {
|
||||
return this.activeState()?.getSelection() ?? {};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Estado calculado
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
getAttributeState(attrId: AttrID): AttributeState | null {
|
||||
return this.activeState()?.getAttributeState(attrId) ?? null;
|
||||
}
|
||||
|
||||
getAllAttributeStates(): Map<AttrID, AttributeState> {
|
||||
return this.activeState()?.getAllAttributeStates() ?? new Map();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Precio
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
getPrice(): PricingResult | null {
|
||||
if (!this.activeObjectId) return null;
|
||||
return this.pricingEngine.calculate(
|
||||
this.activeObjectId,
|
||||
this.getSelection(),
|
||||
this.catalog
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Visual
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
resolveView(sectionId: SectionID, viewId: ViewID): ResolveResult {
|
||||
const objectId = this.activeObjectId;
|
||||
if (!objectId) return { url: '', isFallback: true };
|
||||
|
||||
return this.templateResolver.resolve(
|
||||
{ objectId, sectionId, viewId },
|
||||
this.getSelection(),
|
||||
this.catalog
|
||||
);
|
||||
}
|
||||
|
||||
resolveViewApi(sectionId: SectionID, viewId: ViewID): ApiResolveResult | null {
|
||||
const objectId = this.activeObjectId;
|
||||
if (!objectId) return null;
|
||||
|
||||
return this.templateResolver.resolveApi(
|
||||
{ objectId, sectionId, viewId },
|
||||
this.getSelection(),
|
||||
this.catalog
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Validación
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
isValid(): boolean {
|
||||
return this.activeState()?.isValid() ?? true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Eventos
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
onChange(listener: ChangeListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private activeState(): SelectionState | null {
|
||||
if (!this.activeObjectId) return null;
|
||||
return this.states.get(this.activeObjectId) ?? null;
|
||||
}
|
||||
|
||||
private emit(event: ChangeEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
try { listener(event); } catch { /* listener errors no deben romper el engine */ }
|
||||
}
|
||||
}
|
||||
|
||||
private getAttrDisplayFlags(attrId: AttrID): { affectsVisual: boolean; affectsPrice: boolean } {
|
||||
if (!this.activeObjectId) return { affectsVisual: false, affectsPrice: false };
|
||||
|
||||
const obj = this.catalog.objects[this.activeObjectId];
|
||||
if (!obj) return { affectsVisual: false, affectsPrice: false };
|
||||
|
||||
// Buscar el atributo en global y en secciones
|
||||
const attrs = [
|
||||
...obj.attributes,
|
||||
...Object.values(obj.sections).flatMap(s => s.attributes ?? [])
|
||||
];
|
||||
|
||||
const attr = attrs.find(a => a.id === attrId);
|
||||
if (!attr) return { affectsVisual: false, affectsPrice: false };
|
||||
|
||||
const display = attr.display;
|
||||
return {
|
||||
affectsVisual: Boolean(display.affectsVisual),
|
||||
affectsPrice : Boolean(display.affectsPrice),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
|
||||
|
||||
export * from './configuration.ts';
|
||||
export * from './evaluator.ts';
|
||||
export * from './pricing.ts';
|
||||
export * from './rule.ts';
|
||||
export * from './selection-state.ts';
|
||||
export * from './template-resolver.ts';
|
||||
@ -0,0 +1,3 @@
|
||||
|
||||
export * from './housing-catalog.ts';
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// TYPE GUARDS
|
||||
// ============================================================================
|
||||
|
||||
import type {AttrID, OptionID} from "@/libs/vice/types";
|
||||
|
||||
export function isOptionID(value: unknown): value is OptionID {
|
||||
return typeof value === 'string' && value.startsWith('op:');
|
||||
}
|
||||
|
||||
export function isAttrID(value: unknown): value is AttrID {
|
||||
return typeof value === 'string' && value.startsWith('at:');
|
||||
}
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
|
||||
|
||||
export * from './ids.ts';
|
||||
export * from './option.ts';
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
import type {AttrID, OptionID, OptionSelection, Value} from "../types";
|
||||
|
||||
|
||||
/**
|
||||
* Helper para narrowear el valor de un atributo dinámico.
|
||||
*/
|
||||
export function isOptionSelection(value: OptionSelection | Value): value is OptionSelection {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'optionId' in value
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,361 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* CONFIGURATION ENGINE — TESTS
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ConfigurationEngine } from '../engines/configuration';
|
||||
import type { Logr } from '@/libs/logr';
|
||||
import {
|
||||
CATALOGO_VIVIENDAS,
|
||||
IDS_TEST,
|
||||
} from '../fixtures/housing-catalog.ts';
|
||||
|
||||
const {
|
||||
OBJ_APARTAMENTO,
|
||||
OBJ_DUPLEX,
|
||||
ATTR_CALIDAD,
|
||||
ATTR_SUELO,
|
||||
ATTR_REVEST_BANO,
|
||||
ATTR_SANITARIO,
|
||||
ATTR_ARMARIO_PUERTAS,
|
||||
OPT_CALIDAD_ESTANDAR,
|
||||
OPT_CALIDAD_PREMIUM,
|
||||
OPT_CALIDAD_LUJO,
|
||||
OPT_SUELO_PARQUET,
|
||||
OPT_SUELO_CERAMICA,
|
||||
OPT_REVEST_CERAMICA,
|
||||
OPT_REVEST_MARMOL,
|
||||
OPT_SANIT_NEGRO,
|
||||
OPT_ARMARIO_LACADO,
|
||||
OPT_ARMARIO_MADERA,
|
||||
SEC_SALON,
|
||||
VIEW_FRONT,
|
||||
} = IDS_TEST;
|
||||
|
||||
// ============================================================================
|
||||
// SETUP
|
||||
// ============================================================================
|
||||
|
||||
function makeLogr(): Logr {
|
||||
return {
|
||||
debug : vi.fn(),
|
||||
info : vi.fn(),
|
||||
warn : vi.fn(),
|
||||
error : vi.fn(),
|
||||
getLogs : vi.fn(),
|
||||
clear : vi.fn(),
|
||||
serialize: vi.fn(),
|
||||
setLevel : vi.fn(),
|
||||
setMaxLogs: vi.fn(),
|
||||
} as unknown as Logr;
|
||||
}
|
||||
|
||||
function makeEngine() {
|
||||
return new ConfigurationEngine(CATALOGO_VIVIENDAS, makeLogr());
|
||||
}
|
||||
|
||||
let engine: ConfigurationEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
engine = makeEngine();
|
||||
engine.setActiveObject(OBJ_APARTAMENTO);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// setActiveObject
|
||||
// ============================================================================
|
||||
|
||||
describe('setActiveObject', () => {
|
||||
|
||||
it('establece el objeto activo', () => {
|
||||
expect(engine.getActiveObjectId()).toBe(OBJ_APARTAMENTO);
|
||||
});
|
||||
|
||||
it('objeto inexistente no cambia el activo', () => {
|
||||
engine.setActiveObject('ob:noexiste' as any);
|
||||
expect(engine.getActiveObjectId()).toBe(OBJ_APARTAMENTO);
|
||||
});
|
||||
|
||||
it('cambia el objeto activo', () => {
|
||||
engine.setActiveObject(OBJ_DUPLEX);
|
||||
expect(engine.getActiveObjectId()).toBe(OBJ_DUPLEX);
|
||||
});
|
||||
|
||||
it('emite evento object al cambiar', () => {
|
||||
const listener = vi.fn();
|
||||
engine.onChange(listener);
|
||||
engine.setActiveObject(OBJ_DUPLEX);
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'object', objectId: OBJ_DUPLEX })
|
||||
);
|
||||
});
|
||||
|
||||
it('preserva el estado al volver al objeto anterior', () => {
|
||||
engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
|
||||
engine.setActiveObject(OBJ_DUPLEX);
|
||||
engine.setActiveObject(OBJ_APARTAMENTO);
|
||||
const val = engine.getValue(ATTR_SUELO) as any;
|
||||
expect(val.optionId).toBe(OPT_SUELO_PARQUET);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// select
|
||||
// ============================================================================
|
||||
|
||||
describe('select', () => {
|
||||
|
||||
it('acepta una selección válida', () => {
|
||||
const result = engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rechaza una selección prohibida', () => {
|
||||
const result = engine.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('devuelve reason al rechazar', () => {
|
||||
const result = engine.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) expect(result.reason).toBeTruthy();
|
||||
});
|
||||
|
||||
it('acepta selección con cantidad', () => {
|
||||
const result = engine.select(ATTR_ARMARIO_PUERTAS, OPT_ARMARIO_LACADO, 3);
|
||||
expect(result.success).toBe(true);
|
||||
const val = engine.getValue(ATTR_ARMARIO_PUERTAS) as any;
|
||||
expect(val.quantity).toBe(3);
|
||||
});
|
||||
|
||||
it('affectsVisual true para atributo visual', () => {
|
||||
const result = engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
|
||||
expect(result.affectsVisual).toBe(true);
|
||||
});
|
||||
|
||||
it('affectsPrice true para atributo con precio', () => {
|
||||
const result = engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
|
||||
expect(result.affectsPrice).toBe(true);
|
||||
});
|
||||
|
||||
it('falla si no hay objeto activo', () => {
|
||||
const eng = makeEngine(); // sin setActiveObject
|
||||
const result = eng.select(ATTR_SUELO, OPT_SUELO_PARQUET);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('emite evento selection al seleccionar', () => {
|
||||
const listener = vi.fn();
|
||||
engine.onChange(listener);
|
||||
engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type : 'selection',
|
||||
objectId: OBJ_APARTAMENTO,
|
||||
attrId : ATTR_SUELO,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('no emite evento si la selección es rechazada', () => {
|
||||
const listener = vi.fn();
|
||||
engine.onChange(listener);
|
||||
engine.select(ATTR_REVEST_BANO, OPT_REVEST_MARMOL);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// getSelection / getValue
|
||||
// ============================================================================
|
||||
|
||||
describe('getSelection / getValue', () => {
|
||||
|
||||
it('getSelection devuelve estado del objeto activo', () => {
|
||||
const sel = engine.getSelection();
|
||||
expect(sel[ATTR_CALIDAD]).toBeDefined();
|
||||
});
|
||||
|
||||
it('getSelection devuelve objeto vacío sin objeto activo', () => {
|
||||
const eng = makeEngine();
|
||||
expect(eng.getSelection()).toEqual({});
|
||||
});
|
||||
|
||||
it('getValue devuelve OptionSelection tras selección', () => {
|
||||
engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
|
||||
const val = engine.getValue(ATTR_SUELO) as any;
|
||||
expect(val.optionId).toBe(OPT_SUELO_PARQUET);
|
||||
});
|
||||
|
||||
it('estados de objetos distintos son independientes', () => {
|
||||
engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
|
||||
engine.setActiveObject(OBJ_DUPLEX);
|
||||
const val = engine.getValue(ATTR_SUELO) as any;
|
||||
// Dúplex tiene su propio estado — no el del apartamento
|
||||
expect(val?.optionId).not.toBe(OPT_SUELO_PARQUET);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// getAttributeState / getAllAttributeStates
|
||||
// ============================================================================
|
||||
|
||||
describe('getAttributeState', () => {
|
||||
|
||||
it('devuelve opciones disponibles para un atributo', () => {
|
||||
const s = engine.getAttributeState(ATTR_SUELO);
|
||||
expect(s?.options.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('devuelve null sin objeto activo', () => {
|
||||
const eng = makeEngine();
|
||||
expect(eng.getAttributeState(ATTR_SUELO)).toBeNull();
|
||||
});
|
||||
|
||||
it('mármol no disponible con calidad estándar', () => {
|
||||
const s = engine.getAttributeState(ATTR_REVEST_BANO);
|
||||
const marmol = s?.options.find(o => o.optionId === OPT_REVEST_MARMOL);
|
||||
expect(marmol?.available).toBe(false);
|
||||
});
|
||||
|
||||
it('mármol disponible tras cambiar a premium', () => {
|
||||
engine.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM);
|
||||
const s = engine.getAttributeState(ATTR_REVEST_BANO);
|
||||
const marmol = s?.options.find(o => o.optionId === OPT_REVEST_MARMOL);
|
||||
expect(marmol?.available).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllAttributeStates', () => {
|
||||
|
||||
it('devuelve todos los atributos del objeto activo', () => {
|
||||
const states = engine.getAllAttributeStates();
|
||||
expect(states.size).toBeGreaterThan(0);
|
||||
expect(states.has(ATTR_CALIDAD)).toBe(true);
|
||||
expect(states.has(ATTR_SUELO)).toBe(true);
|
||||
});
|
||||
|
||||
it('devuelve mapa vacío sin objeto activo', () => {
|
||||
const eng = makeEngine();
|
||||
expect(eng.getAllAttributeStates().size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// getPrice
|
||||
// ============================================================================
|
||||
|
||||
describe('getPrice', () => {
|
||||
|
||||
it('devuelve un PricingResult', () => {
|
||||
const price = engine.getPrice();
|
||||
expect(price).not.toBeNull();
|
||||
expect(typeof price?.subtotal).toBe('number');
|
||||
expect(typeof price?.total).toBe('number');
|
||||
});
|
||||
|
||||
it('el precio cambia al cambiar una opción con precio', () => {
|
||||
const before = engine.getPrice()?.subtotal ?? 0;
|
||||
engine.select(ATTR_CALIDAD, OPT_CALIDAD_PREMIUM);
|
||||
const after = engine.getPrice()?.subtotal ?? 0;
|
||||
expect(after).toBeGreaterThan(before);
|
||||
});
|
||||
|
||||
it('devuelve null sin objeto activo', () => {
|
||||
const eng = makeEngine();
|
||||
expect(eng.getPrice()).toBeNull();
|
||||
});
|
||||
|
||||
it('precio con armario 3 puertas lacadas = base + 900', () => {
|
||||
const base = engine.getPrice()!.subtotal;
|
||||
engine.select(ATTR_ARMARIO_PUERTAS, OPT_ARMARIO_LACADO, 3);
|
||||
const after = engine.getPrice()!.subtotal;
|
||||
// Con 2 puertas ya sumaba 700 en el estado inicial
|
||||
// Con 3 puertas suma 900 → diferencia de 200
|
||||
expect(after - base).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// resolveView
|
||||
// ============================================================================
|
||||
|
||||
describe('resolveView', () => {
|
||||
|
||||
it('devuelve una URL para una vista válida', () => {
|
||||
const result = engine.resolveView(SEC_SALON, VIEW_FRONT);
|
||||
expect(typeof result.url).toBe('string');
|
||||
expect(result.url.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('la URL cambia al cambiar una opción visual', () => {
|
||||
const before = engine.resolveView(SEC_SALON, VIEW_FRONT).url;
|
||||
engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
|
||||
const after = engine.resolveView(SEC_SALON, VIEW_FRONT).url;
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
it('devuelve isFallback false para vista válida', () => {
|
||||
const result = engine.resolveView(SEC_SALON, VIEW_FRONT);
|
||||
expect(result.isFallback).toBe(false);
|
||||
});
|
||||
|
||||
it('devuelve isFallback true sin objeto activo', () => {
|
||||
const eng = makeEngine();
|
||||
const result = eng.resolveView(SEC_SALON, VIEW_FRONT);
|
||||
expect(result.isFallback).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// isValid
|
||||
// ============================================================================
|
||||
|
||||
describe('isValid', () => {
|
||||
|
||||
it('estado inicial es válido', () => {
|
||||
expect(engine.isValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('true sin objeto activo', () => {
|
||||
const eng = makeEngine();
|
||||
expect(eng.isValid()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// onChange — eventos
|
||||
// ============================================================================
|
||||
|
||||
describe('onChange', () => {
|
||||
|
||||
it('devuelve función de cleanup', () => {
|
||||
const unsubscribe = engine.onChange(vi.fn());
|
||||
expect(typeof unsubscribe).toBe('function');
|
||||
});
|
||||
|
||||
it('cleanup elimina el listener', () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = engine.onChange(listener);
|
||||
unsubscribe();
|
||||
engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('múltiples listeners reciben el mismo evento', () => {
|
||||
const a = vi.fn();
|
||||
const b = vi.fn();
|
||||
engine.onChange(a);
|
||||
engine.onChange(b);
|
||||
engine.select(ATTR_SUELO, OPT_SUELO_PARQUET);
|
||||
expect(a).toHaveBeenCalledTimes(1);
|
||||
expect(b).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('error en listener no rompe el engine', () => {
|
||||
engine.onChange(() => { throw new Error('listener crash'); });
|
||||
expect(() => engine.select(ATTR_SUELO, OPT_SUELO_PARQUET)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,329 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* RULE ENGINE — TESTS
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { RuleEngine } from '../engines/rule';
|
||||
import type { Logr } from '@/libs/logr';
|
||||
import type { SelectionMap } from '../engines/rule';
|
||||
import {
|
||||
CATALOGO_VIVIENDAS,
|
||||
IDS_TEST
|
||||
} from '../fixtures/housing-catalog.ts';
|
||||
|
||||
const {
|
||||
ATTR_CALIDAD,
|
||||
ATTR_SUELO,
|
||||
ATTR_REVEST_BANO,
|
||||
ATTR_SANITARIO,
|
||||
ATTR_ENCIMERA,
|
||||
ATTR_PARED,
|
||||
ATTR_PUERTA,
|
||||
OPT_CALIDAD_ESTANDAR,
|
||||
OPT_CALIDAD_PREMIUM,
|
||||
OPT_CALIDAD_LUJO,
|
||||
OPT_SUELO_MICROCEMENTO,
|
||||
OPT_SUELO_PARQUET,
|
||||
OPT_REVEST_MARMOL,
|
||||
OPT_REVEST_MICROCEMENTO,
|
||||
OPT_REVEST_CERAMICA,
|
||||
OPT_SANIT_NEGRO,
|
||||
OPT_SANIT_BLANCO,
|
||||
OPT_ENCIMERA_MADERA,
|
||||
OPT_ENCIMERA_GRANITO,
|
||||
OPT_PARED_BLANCO,
|
||||
OPT_PUERTA_LACADA,
|
||||
} = IDS_TEST;
|
||||
|
||||
// ============================================================================
|
||||
// SETUP
|
||||
// ============================================================================
|
||||
|
||||
function makeLogr(): Logr {
|
||||
return {
|
||||
debug : vi.fn(),
|
||||
info : vi.fn(),
|
||||
warn : vi.fn(),
|
||||
error : vi.fn(),
|
||||
getLogs : vi.fn(),
|
||||
clear : vi.fn(),
|
||||
serialize: vi.fn(),
|
||||
setLevel : vi.fn(),
|
||||
setMaxLogs: vi.fn(),
|
||||
} as unknown as Logr;
|
||||
}
|
||||
|
||||
const rules = Object.values(CATALOGO_VIVIENDAS.rules ?? {});
|
||||
|
||||
/**
|
||||
* Estado base válido — todas las opciones compatibles entre sí.
|
||||
* Calidad premium + parquet + pared blanca + sanitario blanco +
|
||||
* revestimiento cerámica + encimera granito + puerta lacada.
|
||||
*/
|
||||
function baseState(): SelectionMap {
|
||||
return {
|
||||
[ATTR_CALIDAD] : OPT_CALIDAD_PREMIUM,
|
||||
[ATTR_SUELO] : OPT_SUELO_PARQUET,
|
||||
[ATTR_PARED] : OPT_PARED_BLANCO,
|
||||
[ATTR_SANITARIO] : OPT_SANIT_BLANCO,
|
||||
[ATTR_REVEST_BANO]: { optionId: OPT_REVEST_CERAMICA },
|
||||
[ATTR_ENCIMERA] : OPT_ENCIMERA_GRANITO,
|
||||
[ATTR_PUERTA] : OPT_PUERTA_LACADA,
|
||||
};
|
||||
}
|
||||
|
||||
let engine: RuleEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
engine = new RuleEngine(makeLogr());
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// isValid — estado base
|
||||
// ============================================================================
|
||||
|
||||
describe('isValid', () => {
|
||||
|
||||
it('estado base compatible es válido', () => {
|
||||
expect(engine.isValid(rules, baseState())).toBe(true);
|
||||
});
|
||||
|
||||
it('calidad estándar + mármol → inválido (error)', () => {
|
||||
const state = {
|
||||
...baseState(),
|
||||
[ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR,
|
||||
[ATTR_REVEST_BANO]: { optionId: OPT_REVEST_MARMOL },
|
||||
};
|
||||
expect(engine.isValid(rules, state)).toBe(false);
|
||||
});
|
||||
|
||||
it('sanitario negro + calidad estándar → inválido (error)', () => {
|
||||
const state = {
|
||||
...baseState(),
|
||||
[ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR,
|
||||
[ATTR_SANITARIO]: { optionId: OPT_SANIT_NEGRO },
|
||||
};
|
||||
expect(engine.isValid(rules, state)).toBe(false);
|
||||
});
|
||||
|
||||
it('sanitario negro + calidad premium → válido', () => {
|
||||
const state = {
|
||||
...baseState(),
|
||||
[ATTR_CALIDAD] : OPT_CALIDAD_PREMIUM,
|
||||
[ATTR_SANITARIO]: { optionId: OPT_SANIT_NEGRO },
|
||||
};
|
||||
expect(engine.isValid(rules, state)).toBe(true);
|
||||
});
|
||||
|
||||
it('encimera madera + sanitario negro → inválido (warning no bloquea)', () => {
|
||||
// warning no bloquea isValid — solo los errores lo hacen
|
||||
const state = {
|
||||
...baseState(),
|
||||
[ATTR_ENCIMERA] : OPT_ENCIMERA_MADERA,
|
||||
[ATTR_SANITARIO]: { optionId: OPT_SANIT_NEGRO },
|
||||
};
|
||||
// severity: 'warning' → isValid sigue siendo true
|
||||
expect(engine.isValid(rules, state)).toBe(true);
|
||||
});
|
||||
|
||||
it('estado vacío sin reglas activas → válido', () => {
|
||||
expect(engine.isValid([], baseState())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// getViolations — detección de violaciones
|
||||
// ============================================================================
|
||||
|
||||
describe('getViolations', () => {
|
||||
|
||||
it('estado base compatible → sin violaciones', () => {
|
||||
expect(engine.getViolations(rules, baseState())).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('calidad estándar + mármol → violación de error', () => {
|
||||
const state = {
|
||||
...baseState(),
|
||||
[ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR,
|
||||
[ATTR_REVEST_BANO]: { optionId: OPT_REVEST_MARMOL },
|
||||
};
|
||||
const violations = engine.getViolations(rules, state);
|
||||
expect(violations.length).toBeGreaterThan(0);
|
||||
expect(violations[0].severity).toBe('error');
|
||||
});
|
||||
|
||||
it('las violaciones se ordenan: error primero, warning después', () => {
|
||||
// sanitario negro + calidad estándar genera error (negro_requiere_premium)
|
||||
// encimera madera + sanitario negro genera warning (madera_no_bano)
|
||||
const state = {
|
||||
...baseState(),
|
||||
[ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR,
|
||||
[ATTR_SANITARIO]: { optionId: OPT_SANIT_NEGRO },
|
||||
[ATTR_ENCIMERA] : OPT_ENCIMERA_MADERA,
|
||||
};
|
||||
const violations = engine.getViolations(rules, state);
|
||||
const severities = violations.map(v => v.severity);
|
||||
const errorIdx = severities.indexOf('error');
|
||||
const warnIdx = severities.indexOf('warning');
|
||||
if (errorIdx !== -1 && warnIdx !== -1) {
|
||||
expect(errorIdx).toBeLessThan(warnIdx);
|
||||
}
|
||||
});
|
||||
|
||||
it('calidad lujo sin mármol → violación de warning (require)', () => {
|
||||
const state = {
|
||||
...baseState(),
|
||||
[ATTR_CALIDAD] : OPT_CALIDAD_LUJO,
|
||||
[ATTR_REVEST_BANO]: { optionId: OPT_REVEST_CERAMICA },
|
||||
};
|
||||
const violations = engine.getViolations(rules, state);
|
||||
expect(violations.some(v => v.severity === 'warning')).toBe(true);
|
||||
});
|
||||
|
||||
it('contiene referencia a la regla violada', () => {
|
||||
const state = {
|
||||
...baseState(),
|
||||
[ATTR_CALIDAD] : OPT_CALIDAD_ESTANDAR,
|
||||
[ATTR_REVEST_BANO]: { optionId: OPT_REVEST_MARMOL },
|
||||
};
|
||||
const violations = engine.getViolations(rules, state);
|
||||
expect(violations[0].rule).toBeDefined();
|
||||
expect(violations[0].rule.id).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// isValueAllowed
|
||||
// ============================================================================
|
||||
|
||||
describe('isValueAllowed', () => {
|
||||
|
||||
it('mármol permitido con calidad premium', () => {
|
||||
const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_PREMIUM } };
|
||||
expect(engine.isValueAllowed(ATTR_REVEST_BANO, OPT_REVEST_MARMOL, rules, state)).toBe(true);
|
||||
});
|
||||
|
||||
it('mármol prohibido con calidad estándar', () => {
|
||||
const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_ESTANDAR } };
|
||||
expect(engine.isValueAllowed(ATTR_REVEST_BANO, OPT_REVEST_MARMOL, rules, state)).toBe(false);
|
||||
});
|
||||
|
||||
it('sanitario negro prohibido con calidad estándar', () => {
|
||||
const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_ESTANDAR } };
|
||||
expect(engine.isValueAllowed(ATTR_SANITARIO, OPT_SANIT_NEGRO, rules, state)).toBe(false);
|
||||
});
|
||||
|
||||
it('sanitario negro permitido con calidad premium', () => {
|
||||
const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_PREMIUM } };
|
||||
expect(engine.isValueAllowed(ATTR_SANITARIO, OPT_SANIT_NEGRO, rules, state)).toBe(true);
|
||||
});
|
||||
|
||||
it('microcemento en baño permitido con suelo microcemento (allow)', () => {
|
||||
const state = { ...baseState(), [ATTR_SUELO]: { optionId: OPT_SUELO_MICROCEMENTO } };
|
||||
expect(engine.isValueAllowed(ATTR_REVEST_BANO, OPT_REVEST_MICROCEMENTO, rules, state)).toBe(true);
|
||||
});
|
||||
|
||||
it('mármol en baño no permitido con suelo microcemento (allow limita lista)', () => {
|
||||
const state = { ...baseState(), [ATTR_SUELO]: { optionId: OPT_SUELO_MICROCEMENTO } };
|
||||
expect(engine.isValueAllowed(ATTR_REVEST_BANO, OPT_REVEST_MARMOL, rules, state)).toBe(false);
|
||||
});
|
||||
|
||||
it('cualquier valor permitido para atributo sin reglas activas', () => {
|
||||
expect(engine.isValueAllowed(ATTR_PARED, OPT_PARED_BLANCO, rules, baseState())).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// getAllowedValues
|
||||
// ============================================================================
|
||||
|
||||
describe('getAllowedValues', () => {
|
||||
|
||||
it('devuelve undefined si no hay restricción de allow activa', () => {
|
||||
const state = { ...baseState(), [ATTR_SUELO]: { optionId: OPT_SUELO_PARQUET } };
|
||||
expect(engine.getAllowedValues(ATTR_REVEST_BANO, rules, state)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('con suelo microcemento devuelve lista restringida para revest_bano', () => {
|
||||
const state = { ...baseState(), [ATTR_SUELO]: { optionId: OPT_SUELO_MICROCEMENTO } };
|
||||
const allowed = engine.getAllowedValues(ATTR_REVEST_BANO, rules, state);
|
||||
expect(allowed).toBeDefined();
|
||||
expect(allowed).toContain(OPT_REVEST_MICROCEMENTO);
|
||||
expect(allowed).toContain(OPT_REVEST_CERAMICA);
|
||||
expect(allowed).not.toContain(OPT_REVEST_MARMOL);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// isRequired
|
||||
// ============================================================================
|
||||
|
||||
describe('isRequired', () => {
|
||||
|
||||
it('revest_bano no requerido con calidad premium', () => {
|
||||
const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_PREMIUM } };
|
||||
expect(engine.isRequired(ATTR_REVEST_BANO, rules, state)).toBe(false);
|
||||
});
|
||||
|
||||
it('revest_bano requerido con calidad lujo', () => {
|
||||
const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_LUJO } };
|
||||
expect(engine.isRequired(ATTR_REVEST_BANO, rules, state)).toBe(true);
|
||||
});
|
||||
|
||||
it('atributo sin regla de require → no requerido', () => {
|
||||
expect(engine.isRequired(ATTR_PARED, rules, baseState())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// evaluateAll — prioridad
|
||||
// ============================================================================
|
||||
|
||||
describe('evaluateAll — prioridad', () => {
|
||||
|
||||
it('las reglas se devuelven ordenadas por prioridad descendente', () => {
|
||||
const results = engine.evaluateAll(rules, baseState());
|
||||
const priorities = results.map(r => r.rule.priority);
|
||||
for (let i = 0; i < priorities.length - 1; i++) {
|
||||
expect(priorities[i]).toBeGreaterThanOrEqual(priorities[i + 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('regla con condición que no se cumple → triggered: false', () => {
|
||||
// Con calidad premium la regla estandar_no_lujo no se activa
|
||||
const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_PREMIUM } };
|
||||
const results = engine.evaluateAll(rules, state);
|
||||
const estandarNoLujo = results.find(r => r.rule.id === IDS_TEST.RULE_ESTANDAR_NO_LUJO);
|
||||
expect(estandarNoLujo?.triggered).toBe(false);
|
||||
});
|
||||
|
||||
it('regla con condición que se cumple → triggered: true', () => {
|
||||
const state = { ...baseState(), [ATTR_CALIDAD]: { optionId: OPT_CALIDAD_ESTANDAR } };
|
||||
const results = engine.evaluateAll(rules, state);
|
||||
const estandarNoLujo = results.find(r => r.rule.id === IDS_TEST.RULE_ESTANDAR_NO_LUJO);
|
||||
expect(estandarNoLujo?.triggered).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// evaluateRule — error handling
|
||||
// ============================================================================
|
||||
|
||||
describe('evaluateRule — error handling', () => {
|
||||
|
||||
it('regla con condición malformada → triggered: false y logea error', () => {
|
||||
const logr = makeLogr();
|
||||
const eng = new RuleEngine(logr);
|
||||
const badRule = {
|
||||
...Object.values(CATALOGO_VIVIENDAS.rules ?? {})[0],
|
||||
id : 'rl:test_bad' as any,
|
||||
condition: { 'operadorQueNoExiste': null } as any,
|
||||
};
|
||||
const result = eng.evaluateRule(badRule, baseState());
|
||||
// El evaluador devuelve null para operadores desconocidos → triggered: false
|
||||
expect(result.triggered).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,16 +1,22 @@
|
||||
|
||||
|
||||
export * from './primitives.ts';
|
||||
export * from './ids.ts';
|
||||
export * from './datas.ts';
|
||||
|
||||
export * from './api.ts';
|
||||
export * from './attribute.ts';
|
||||
export * from './catalog.ts';
|
||||
export * from './datas.ts';
|
||||
export * from './hotspot.ts';
|
||||
export * from './ids.ts';
|
||||
export * from './json-logic.ts';
|
||||
export * from './object.ts';
|
||||
export * from './option.ts';
|
||||
export * from './pricing.ts';
|
||||
export * from './primitives.ts';
|
||||
export * from './rule.ts';
|
||||
export * from './section.ts';
|
||||
export * from './selection.ts';
|
||||
export * from './view.ts';
|
||||
export * from './object.ts';
|
||||
export * from './catalog.ts';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
// ============================================================================
|
||||
// OPTION SELECTION
|
||||
// ============================================================================
|
||||
|
||||
import type {AttrID, OptionID} from "./ids.ts";
|
||||
import type {Value} from "./primitives";
|
||||
|
||||
// ============================================================================
|
||||
// OPTION SELECTION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Selección de una opción — optionId + cantidad opcional.
|
||||
* La cantidad solo está presente si la AttributeOption tiene QuantityConfig.
|
||||
*
|
||||
* @example
|
||||
* // Opción simple
|
||||
* { optionId: 'op:suelo_parquet' }
|
||||
*
|
||||
* // Opción cuantificable
|
||||
* { optionId: 'op:luz_led', quantity: 3 }
|
||||
*/
|
||||
export interface OptionSelection {
|
||||
optionId : OptionID;
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SELECTION MAP
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Estado de selección del usuario.
|
||||
* Clave: AttrID — Valor: OptionSelection o Value (para FixedAttribute)
|
||||
*
|
||||
* Los atributos FixedAttribute y ComputedAttribute usan Value directamente.
|
||||
* Los atributos DynamicAttribute usan OptionSelection.
|
||||
*/
|
||||
export type SelectionMap = Record<AttrID, OptionSelection | Value>;
|
||||
|
||||
|
||||
Loading…
Reference in new issue