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 './api.ts';
|
||||||
export * from './attribute.ts';
|
export * from './attribute.ts';
|
||||||
|
export * from './catalog.ts';
|
||||||
|
export * from './datas.ts';
|
||||||
export * from './hotspot.ts';
|
export * from './hotspot.ts';
|
||||||
|
export * from './ids.ts';
|
||||||
export * from './json-logic.ts';
|
export * from './json-logic.ts';
|
||||||
|
export * from './object.ts';
|
||||||
export * from './option.ts';
|
export * from './option.ts';
|
||||||
export * from './pricing.ts';
|
export * from './pricing.ts';
|
||||||
|
export * from './primitives.ts';
|
||||||
export * from './rule.ts';
|
export * from './rule.ts';
|
||||||
export * from './section.ts';
|
export * from './section.ts';
|
||||||
|
export * from './selection.ts';
|
||||||
export * from './view.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