master
parent
c3551e9751
commit
bde12d5988
@ -1,23 +0,0 @@
|
||||
// ── JsonLogic ─────────────────────────────────────────────────────────────────
|
||||
// Subconjunto del estándar jsonlogic.com suficiente para las reglas del motor.
|
||||
// En el proyecto real importar desde @/vice/engine.ts o usar la librería.
|
||||
|
||||
export type JsonLogic =
|
||||
| { var: string }
|
||||
| { [op: string]: JsonLogic | JsonLogic[] | unknown }
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null// json_logic.ts
|
||||
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// JSON LOGIC EVALUATOR
|
||||
// Interfaz para inyectar el evaluador de JsonLogic.
|
||||
// El motor no tiene dependencia directa de la librería.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type JsonLogicEvaluator = (logic: JsonLogic, data: Record<string, unknown>) => unknown
|
||||
|
||||
@ -0,0 +1,206 @@
|
||||
import { JsonLogicEngine } from '@/jslg';
|
||||
import type {
|
||||
Cat,
|
||||
Att,
|
||||
DependencyGraph,
|
||||
EvaluationResult, AttChange
|
||||
} from './types';
|
||||
import { validateAttValue } from './validation';
|
||||
import {
|
||||
serializeAttPath,
|
||||
buildConfigState
|
||||
} from './node_util';
|
||||
import { evaluateObj } from './engine';
|
||||
import { getTransitiveAffected } from './graph';
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// evaluateIncremental
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Re-evalúa solo los atributos afectados por un cambio de valor,
|
||||
* fusionando el resultado con el `EvaluationResult` previo.
|
||||
*
|
||||
* ## Algoritmo
|
||||
*
|
||||
* 1. Aplicar el cambio al `ConfigState` derivado del resultado previo
|
||||
* 2. Usar el grafo para obtener los atts afectados transitivamente
|
||||
* 3. Re-evaluar solo los `Obj` que contienen atts afectados
|
||||
* 4. Fusionar el nuevo `EffectiveState` parcial con el previo
|
||||
* 5. Re-validar los atts afectados contra el estado fusionado
|
||||
* 6. Devolver un `EvaluationResult` completo actualizado
|
||||
*
|
||||
* @param cat - Catálogo completo (no mutable)
|
||||
* @param graph - Grafo de dependencias preconstruido
|
||||
* @param prev - Resultado de la evaluación anterior
|
||||
* @param change - Cambio de valor a aplicar
|
||||
* @param engine - Instancia JsonLogicEngine (null = nueva instancia)
|
||||
*/
|
||||
export function evaluateIncremental(
|
||||
cat: Cat,
|
||||
graph: DependencyGraph,
|
||||
prev: EvaluationResult,
|
||||
change: AttChange,
|
||||
engine: JsonLogicEngine | null = null,
|
||||
): EvaluationResult {
|
||||
const resolvedEngine = engine ?? new JsonLogicEngine();
|
||||
const changedKey = serializeAttPath(change.path);
|
||||
|
||||
// ── 1. Construir ConfigState actualizado ──────────────────────────────────
|
||||
// Partir del ConfigState derivado del resultado previo
|
||||
// (incorpora derivedValues del ciclo anterior)
|
||||
const state = buildStateFromResult(cat, prev);
|
||||
state[changedKey] = change.value;
|
||||
|
||||
// ── 2. Atts afectados transitivamente ─────────────────────────────────────
|
||||
const transitivelyAffected = getTransitiveAffected(graph, change.path);
|
||||
const affectedKeys = new Set<string>([
|
||||
changedKey,
|
||||
...transitivelyAffected.map(serializeAttPath),
|
||||
]);
|
||||
|
||||
// ── 3. Identificar Objs que contienen atts afectados ─────────────────────
|
||||
const affectedObjKeys = new Set<string>();
|
||||
for (const key of affectedKeys) {
|
||||
const parts = key.split('/');
|
||||
// key = ct:cat/ob:obj/... → objKey = ct:cat/ob:obj
|
||||
if (parts.length >= 2) {
|
||||
affectedObjKeys.add(`${parts[0]}/${parts[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Re-evaluar los Objs afectados con forward chaining parcial ────────
|
||||
const nextEffective = new Map(prev.effectiveState);
|
||||
let currentState = { ...state };
|
||||
let prevSnapshot = '';
|
||||
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const iterEffective = new Map(nextEffective);
|
||||
|
||||
for (const obj of cat.objs) {
|
||||
const objKey = `${cat.id}/${obj.id}`;
|
||||
if (!affectedObjKeys.has(objKey)) continue;
|
||||
|
||||
const objResult = evaluateObj(obj, cat.id, currentState, resolvedEngine);
|
||||
for (const [k, v] of objResult) {
|
||||
iterEffective.set(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
// Propagar derivedValues de los atts afectados al estado
|
||||
const nextState = { ...currentState };
|
||||
for (const key of affectedKeys) {
|
||||
const es = iterEffective.get(key);
|
||||
if (es?.derivedValue !== undefined) {
|
||||
nextState[key] = es.derivedValue.value;
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = JSON.stringify([...iterEffective.entries()]
|
||||
.filter(([k]) => affectedKeys.has(k))
|
||||
.sort(([a], [b]) => a.localeCompare(b)));
|
||||
|
||||
for (const [k, v] of iterEffective) nextEffective.set(k, v);
|
||||
|
||||
if (snapshot === prevSnapshot) break;
|
||||
prevSnapshot = snapshot;
|
||||
currentState = nextState;
|
||||
}
|
||||
|
||||
// ── 5. Re-validar atts afectados ──────────────────────────────────────────
|
||||
const errors = new Set(prev.errors.filter(p => !affectedKeys.has(p)));
|
||||
const warnings = new Set(prev.warnings.filter(p => !affectedKeys.has(p)));
|
||||
|
||||
for (const key of affectedKeys) {
|
||||
const parts = key.split('/');
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
const att = findAtt(cat, key);
|
||||
if (!att) continue;
|
||||
|
||||
const es = nextEffective.get(key);
|
||||
if (!es) continue;
|
||||
|
||||
// Att forbidden con valor → error
|
||||
const currentValue = state[key];
|
||||
if (es.state === 'forbidden' && currentValue !== undefined && currentValue !== null) {
|
||||
errors.add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (es.state === 'hidden' || es.state === 'forbidden') continue;
|
||||
|
||||
const effectiveValue = es.derivedValue ?? att.value;
|
||||
const domainResult = validateAttValue(effectiveValue);
|
||||
if (!domainResult.valid) { errors.add(key); continue; }
|
||||
|
||||
const rawValue = effectiveValue.value;
|
||||
const values = Array.isArray(rawValue) ? rawValue : [rawValue];
|
||||
const forbidden = values.some(v =>
|
||||
es.forbiddenValues.includes(v as any)
|
||||
);
|
||||
if (forbidden) { errors.add(key); continue; }
|
||||
|
||||
for (const msg of es.messages) {
|
||||
if (msg.level === 'error') errors.add(key);
|
||||
if (msg.level === 'warning') warnings.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
effectiveState: nextEffective,
|
||||
valid: errors.size === 0,
|
||||
errors: [...errors],
|
||||
warnings: [...warnings],
|
||||
iterations: prev.iterations, // no se re-hace forward chaining completo
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HELPERS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reconstruye el ConfigState a partir del Cat y el EvaluationResult previo.
|
||||
* Incorpora los derivedValues para que el estado refleje la realidad actual.
|
||||
*/
|
||||
function buildStateFromResult(cat: Cat, prev: EvaluationResult): Record<string, unknown> {
|
||||
const state = buildConfigState(cat);
|
||||
|
||||
// Sobreescribir con derivedValues del resultado previo
|
||||
for (const [path, es] of prev.effectiveState) {
|
||||
if (es.derivedValue !== undefined) {
|
||||
state[path] = es.derivedValue.value;
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encuentra un `Att` por su path serializado dentro del `Cat`.
|
||||
*/
|
||||
function findAtt(cat: Cat, key: string): Att | undefined {
|
||||
const parts = key.split('/');
|
||||
|
||||
if (parts.length === 2) {
|
||||
// ct:cat/at:x
|
||||
return cat.atts.find(a => a.id === parts[1]);
|
||||
}
|
||||
|
||||
if (parts.length === 3) {
|
||||
// ct:cat/ob:obj/at:x
|
||||
const obj = cat.objs.find(o => `${cat.id}/${o.id}` === `${parts[0]}/${parts[1]}`);
|
||||
return obj?.atts.find(a => a.id === parts[2]);
|
||||
}
|
||||
|
||||
if (parts.length === 4) {
|
||||
// ct:cat/ob:obj/sc:sec/at:x
|
||||
const obj = cat.objs.find(o => o.id === parts[1]);
|
||||
const sec = obj?.secs.find(s => s.id === parts[2]);
|
||||
return sec?.atts.find(a => a.id === parts[3]);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@ -0,0 +1,203 @@
|
||||
import { JsonLogicEngine } from '@/jslg';
|
||||
import type {
|
||||
Cat,
|
||||
AttEffectiveState,
|
||||
EvaluationResult,
|
||||
AttPath,
|
||||
DependencyGraph
|
||||
} from './types';
|
||||
import { serializeAttPath } from './node_util';
|
||||
import { buildDependencyGraph } from './graph';
|
||||
import { evaluateCat } from './engine';
|
||||
import { evaluateIncremental } from './incremental';
|
||||
import type {SessionListener} from "@/vcen/types/session.ts";
|
||||
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SESSION
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sesión de configuración en runtime.
|
||||
*
|
||||
* Encapsula el `Cat`, el `DependencyGraph` y el `EvaluationResult` actual.
|
||||
* Expone una API limpia para que la UI pueda leer y modificar valores
|
||||
* sin conocer los detalles del motor de evaluación.
|
||||
*
|
||||
* ## Ciclo de vida
|
||||
*
|
||||
* ```
|
||||
* const session = createSession(cat); // carga inicial
|
||||
* session.setValue(path, value); // el usuario cambia un valor
|
||||
* session.getValue(path); // leer valor actual
|
||||
* session.getEffectiveState(path); // leer estado efectivo
|
||||
* session.reset(); // volver al estado inicial
|
||||
* ```
|
||||
*
|
||||
* ## Observers
|
||||
*
|
||||
* ```
|
||||
* const unsub = session.subscribe(result => { ... });
|
||||
* unsub(); // cancelar suscripción
|
||||
* ```
|
||||
*/
|
||||
export class Session {
|
||||
private readonly cat: Cat;
|
||||
private readonly graph: DependencyGraph;
|
||||
private readonly engine: JsonLogicEngine;
|
||||
private readonly initialResult: EvaluationResult;
|
||||
private current: EvaluationResult;
|
||||
private readonly listeners: Set<SessionListener> = new Set();
|
||||
|
||||
constructor(
|
||||
cat: Cat,
|
||||
engine: JsonLogicEngine | null = null,
|
||||
) {
|
||||
this.cat = cat;
|
||||
this.engine = engine ?? new JsonLogicEngine();
|
||||
this.graph = buildDependencyGraph(cat, this.engine);
|
||||
|
||||
const initial = evaluateCat(cat, this.engine);
|
||||
this.initialResult = initial;
|
||||
this.current = initial;
|
||||
}
|
||||
|
||||
// ── Lectura ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Valor actual de un atributo.
|
||||
* Si el motor ha derivado un valor, devuelve el derivado.
|
||||
*/
|
||||
getValue(path: AttPath): unknown {
|
||||
const key = serializeAttPath(path);
|
||||
const es = this.current.effectiveState.get(key);
|
||||
return es?.derivedValue?.value ?? this.findAttValue(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estado efectivo de un atributo (available, hidden, required, forbidden,
|
||||
* forbiddenValues, messages, derivedValue).
|
||||
*/
|
||||
getEffectiveState(path: AttPath): AttEffectiveState | undefined {
|
||||
return this.current.effectiveState.get(serializeAttPath(path));
|
||||
}
|
||||
|
||||
/** Lista de paths con errores en el estado actual. */
|
||||
getErrors(): string[] {
|
||||
return this.current.errors;
|
||||
}
|
||||
|
||||
/** Lista de paths con warnings en el estado actual. */
|
||||
getWarnings(): string[] {
|
||||
return this.current.warnings;
|
||||
}
|
||||
|
||||
/** `true` si la configuración actual es válida. */
|
||||
isValid(): boolean {
|
||||
return this.current.valid;
|
||||
}
|
||||
|
||||
/** EvaluationResult completo actual — para la UI que necesita el estado completo. */
|
||||
getResult(): EvaluationResult {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
// ── Mutación ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cambia el valor de un atributo y re-evalúa incrementalmente.
|
||||
* Notifica a todos los listeners con el nuevo resultado.
|
||||
*
|
||||
* @returns El nuevo `EvaluationResult` tras el cambio.
|
||||
*/
|
||||
setValue(path: AttPath, value: unknown): EvaluationResult {
|
||||
const next = evaluateIncremental(
|
||||
this.cat,
|
||||
this.graph,
|
||||
this.current,
|
||||
{ path, value },
|
||||
this.engine,
|
||||
);
|
||||
|
||||
this.current = next;
|
||||
this.notify();
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vuelve al estado inicial (valores del Cat original, evaluación completa).
|
||||
* Notifica a todos los listeners.
|
||||
*/
|
||||
reset(): EvaluationResult {
|
||||
this.current = this.initialResult;
|
||||
this.notify();
|
||||
return this.current;
|
||||
}
|
||||
|
||||
// ── Observers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Suscribe un listener que se llama cada vez que cambia el estado.
|
||||
*
|
||||
* @returns Función para cancelar la suscripción.
|
||||
*
|
||||
* @example
|
||||
* const unsub = session.subscribe(result => render(result));
|
||||
* // más tarde:
|
||||
* unsub();
|
||||
*/
|
||||
subscribe(listener: SessionListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
// ── Internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
private notify() {
|
||||
for (const listener of this.listeners) {
|
||||
listener(this.current);
|
||||
}
|
||||
}
|
||||
|
||||
private findAttValue(key: string): unknown {
|
||||
const parts = key.split('/');
|
||||
|
||||
if (parts.length === 2) {
|
||||
return this.cat.atts.find(a => a.id === parts[1])?.value.value;
|
||||
}
|
||||
if (parts.length === 3) {
|
||||
const obj = this.cat.objs.find(o => o.id === parts[1]);
|
||||
return obj?.atts.find(a => a.id === parts[2])?.value.value;
|
||||
}
|
||||
if (parts.length === 4) {
|
||||
const obj = this.cat.objs.find(o => o.id === parts[1]);
|
||||
const sec = obj?.secs.find(s => s.id === parts[2]);
|
||||
return sec?.atts.find(a => a.id === parts[3])?.value.value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FACTORY
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Crea una nueva sesión de configuración.
|
||||
*
|
||||
* @throws DependencyCycleError si el Cat tiene ciclos en sus reglas
|
||||
*
|
||||
* @example
|
||||
* const session = createSession(cat);
|
||||
* session.setValue(['ct_cars', 'ob_bmw', 'at_acabado'], 'lujo');
|
||||
* session.isValid(); // true
|
||||
*/
|
||||
export function createSession(
|
||||
cat: Cat,
|
||||
engine: JsonLogicEngine | null = null,
|
||||
): Session {
|
||||
return new Session(cat, engine);
|
||||
}
|
||||
@ -0,0 +1,281 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { evaluateIncremental } from '../incremental';
|
||||
import { evaluateCat } from '../engine';
|
||||
import { buildDependencyGraph } from '../graph';
|
||||
import type { Att, Sec, Obj, Cat, Rule } from '../types';
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FIXTURES
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeAtt(id: string, value: unknown = 'x', rules?: Rule[]): Att {
|
||||
return {
|
||||
id: id as Att['id'],
|
||||
name: id,
|
||||
value: { domain: { kind: 'string', mode: 'input' }, value: value as string },
|
||||
rules: rules ? { rules } : undefined,
|
||||
};
|
||||
}
|
||||
function makeObj(id: string, atts: Att[], secs: Sec[] = [], rules?: Rule[]): Obj {
|
||||
return { id: id as Obj['id'], name: id, atts, secs, rules: rules ? { rules } : undefined };
|
||||
}
|
||||
|
||||
function makeCat(id: string, objs: Obj[], atts: Att[] = []): Cat {
|
||||
return { id: id as Cat['id'], name: id, atts, objs };
|
||||
}
|
||||
|
||||
function makeRule(id: string, condition: unknown, actions: Rule['actions'], priority = 10): Rule {
|
||||
return { id: `rl_${id}` as Rule['id'], priority, condition: condition as Rule['condition'], actions };
|
||||
}
|
||||
|
||||
function ap(...parts: string[]) { return parts as any; }
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HELPERS para setup
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function setup(cat: Cat) {
|
||||
const graph = buildDependencyGraph(cat);
|
||||
const prev = evaluateCat(cat);
|
||||
return { graph, prev };
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// evaluateIncremental — comportamiento básico
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('evaluateIncremental — comportamiento básico', () => {
|
||||
it('devuelve EvaluationResult con effectiveState completo', () => {
|
||||
const obj = makeObj('ob_bmw', [
|
||||
makeAtt('at_color', 'rojo'),
|
||||
makeAtt('at_acabado', 'standard'),
|
||||
]);
|
||||
const cat = makeCat('ct_cars', [obj]);
|
||||
const { graph, prev } = setup(cat);
|
||||
|
||||
const result = evaluateIncremental(
|
||||
cat, graph, prev,
|
||||
{ path: ap('ct_cars', 'ob_bmw', 'at_color'), value: 'azul' },
|
||||
);
|
||||
|
||||
expect(result.effectiveState.size).toBe(prev.effectiveState.size);
|
||||
});
|
||||
|
||||
it('no modifica el EvaluationResult previo (inmutabilidad)', () => {
|
||||
const obj = makeObj('ob_bmw', [makeAtt('at_color', 'rojo')]);
|
||||
const cat = makeCat('ct_cars', [obj]);
|
||||
const { graph, prev } = setup(cat);
|
||||
const prevSize = prev.effectiveState.size;
|
||||
|
||||
evaluateIncremental(
|
||||
cat, graph, prev,
|
||||
{ path: ap('ct_cars', 'ob_bmw', 'at_color'), value: 'azul' },
|
||||
);
|
||||
|
||||
expect(prev.effectiveState.size).toBe(prevSize);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// evaluateIncremental — re-evaluación de atts afectados
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('evaluateIncremental — re-evaluación de atts afectados', () => {
|
||||
it('actualiza el estado de un att afectado por el cambio', () => {
|
||||
// at_tapiceria tiene regla: si at_acabado != lujo → piel forbidden
|
||||
const rule = makeRule('r1', { '!=': [{ var: 'at_acabado' }, 'lujo'] }, [
|
||||
{ type: 'domain', target: [], value: 'piel', state: 'forbidden' },
|
||||
]);
|
||||
const obj = makeObj('ob_bmw', [
|
||||
makeAtt('at_acabado', 'standard'),
|
||||
makeAtt('at_tapiceria', 'tela', [rule]),
|
||||
]);
|
||||
const cat = makeCat('ct_cars', [obj]);
|
||||
const { graph, prev } = setup(cat);
|
||||
|
||||
// Antes: acabado=standard → piel forbidden
|
||||
const before = prev.effectiveState.get('ct_cars/ob_bmw/at_tapiceria');
|
||||
expect(before?.forbiddenValues).toContain('piel');
|
||||
|
||||
// Cambiar acabado a 'lujo' → piel ya no debería estar forbidden
|
||||
const result = evaluateIncremental(
|
||||
cat, graph, prev,
|
||||
{ path: ap('ct_cars', 'ob_bmw', 'at_acabado'), value: 'lujo' },
|
||||
);
|
||||
|
||||
const after = result.effectiveState.get('ct_cars/ob_bmw/at_tapiceria');
|
||||
expect(after?.forbiddenValues).not.toContain('piel');
|
||||
});
|
||||
|
||||
it('no modifica atts no afectados por el cambio', () => {
|
||||
const rule = makeRule('r1', { '==': [{ var: 'at_acabado' }, 'lujo'] }, [
|
||||
{ type: 'attribute', target: [], state: 'required' },
|
||||
]);
|
||||
const obj = makeObj('ob_bmw', [
|
||||
makeAtt('at_acabado', 'standard'),
|
||||
makeAtt('at_color', 'rojo', [rule]),
|
||||
makeAtt('at_motor', 'diesel'), // sin dependencias
|
||||
]);
|
||||
const cat = makeCat('ct_cars', [obj]);
|
||||
const { graph, prev } = setup(cat);
|
||||
|
||||
const motorBefore = prev.effectiveState.get('ct_cars/ob_bmw/at_motor');
|
||||
|
||||
const result = evaluateIncremental(
|
||||
cat, graph, prev,
|
||||
{ path: ap('ct_cars', 'ob_bmw', 'at_acabado'), value: 'lujo' },
|
||||
);
|
||||
|
||||
// at_motor no tiene dependencias — su estado no cambia
|
||||
const motorAfter = result.effectiveState.get('ct_cars/ob_bmw/at_motor');
|
||||
expect(motorAfter).toEqual(motorBefore);
|
||||
});
|
||||
|
||||
it('propaga cambio transitivo A→B→C', () => {
|
||||
// at_motor → at_emisiones (valor derivado 0) → at_etiqueta (hidden si emisiones=0)
|
||||
const rDerive = makeRule('r-derive',
|
||||
{ '==': [{ var: 'at_motor' }, 'electrico'] },
|
||||
[{ type: 'value', target: ['at_emisiones' as Att['id']], value: {
|
||||
domain: { kind: 'string', mode: 'input' }, value: '0',
|
||||
}}],
|
||||
5,
|
||||
);
|
||||
const rHide = makeRule('r-hide',
|
||||
{ '==': [{ var: 'at_emisiones' }, '0'] },
|
||||
[{ type: 'attribute', target: [], state: 'hidden' }],
|
||||
10,
|
||||
);
|
||||
const obj = makeObj('ob_bmw', [
|
||||
makeAtt('at_motor', 'diesel'),
|
||||
makeAtt('at_emisiones', '120', [rDerive]),
|
||||
makeAtt('at_etiqueta', 'C', [rHide]),
|
||||
]);
|
||||
const cat = makeCat('ct_cars', [obj]);
|
||||
const { graph, prev } = setup(cat);
|
||||
|
||||
// Antes: motor=diesel → emisiones no derivado → etiqueta available
|
||||
expect(prev.effectiveState.get('ct_cars/ob_bmw/at_etiqueta')?.state).toBe('available');
|
||||
|
||||
// Cambiar motor a electrico → emisiones se fuerza a '0' → etiqueta hidden
|
||||
const result = evaluateIncremental(
|
||||
cat, graph, prev,
|
||||
{ path: ap('ct_cars', 'ob_bmw', 'at_motor'), value: 'electrico' },
|
||||
);
|
||||
|
||||
expect(result.effectiveState.get('ct_cars/ob_bmw/at_etiqueta')?.state).toBe('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// evaluateIncremental — validación
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('evaluateIncremental — validación', () => {
|
||||
it('marca error cuando el nuevo valor está en forbiddenValues', () => {
|
||||
const rule = makeRule('r1', { '!=': [{ var: 'at_acabado' }, 'lujo'] }, [
|
||||
{ type: 'domain', target: [], value: 'piel', state: 'forbidden' },
|
||||
]);
|
||||
const obj = makeObj('ob_bmw', [
|
||||
makeAtt('at_acabado', 'lujo'), // inicialmente lujo → piel allowed
|
||||
makeAtt('at_tapiceria', 'piel', [rule]),
|
||||
]);
|
||||
const cat = makeCat('ct_cars', [obj]);
|
||||
const { graph, prev } = setup(cat);
|
||||
|
||||
expect(prev.valid).toBe(true);
|
||||
|
||||
// Cambiar acabado a standard → piel forbidden → at_tapiceria tiene valor forbidden
|
||||
const result = evaluateIncremental(
|
||||
cat, graph, prev,
|
||||
{ path: ap('ct_cars', 'ob_bmw', 'at_acabado'), value: 'standard' },
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('ct_cars/ob_bmw/at_tapiceria');
|
||||
});
|
||||
|
||||
it('limpia errores previos de atts afectados que ya son válidos', () => {
|
||||
const rule = makeRule('r1', { '!=': [{ var: 'at_acabado' }, 'lujo'] }, [
|
||||
{ type: 'domain', target: [], value: 'piel', state: 'forbidden' },
|
||||
]);
|
||||
const obj = makeObj('ob_bmw', [
|
||||
makeAtt('at_acabado', 'standard'), // piel forbidden
|
||||
makeAtt('at_tapiceria', 'piel', [rule]),
|
||||
]);
|
||||
const cat = makeCat('ct_cars', [obj]);
|
||||
const { graph, prev } = setup(cat);
|
||||
|
||||
expect(prev.valid).toBe(false);
|
||||
|
||||
// Cambiar acabado a lujo → piel allowed → error desaparece
|
||||
const result = evaluateIncremental(
|
||||
cat, graph, prev,
|
||||
{ path: ap('ct_cars', 'ob_bmw', 'at_acabado'), value: 'lujo' },
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).not.toContain('ct_cars/ob_bmw/at_tapiceria');
|
||||
});
|
||||
|
||||
it('preserva errores de atts no afectados', () => {
|
||||
const rError = makeRule('r-error', true, [
|
||||
{ type: 'validation', level: 'error', message: 'siempre error' },
|
||||
]);
|
||||
const obj = makeObj('ob_bmw', [
|
||||
makeAtt('at_color', 'rojo'),
|
||||
makeAtt('at_motor', 'diesel', [rError]), // siempre error
|
||||
]);
|
||||
const cat = makeCat('ct_cars', [obj]);
|
||||
const { graph, prev } = setup(cat);
|
||||
|
||||
expect(prev.valid).toBe(false);
|
||||
|
||||
// Cambiar at_color no afecta a at_motor
|
||||
const result = evaluateIncremental(
|
||||
cat, graph, prev,
|
||||
{ path: ap('ct_cars', 'ob_bmw', 'at_color'), value: 'azul' },
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('ct_cars/ob_bmw/at_motor');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// evaluateIncremental — consistencia con evaluateCat
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('evaluateIncremental — consistencia con evaluateCat', () => {
|
||||
it('produce el mismo EffectiveState que evaluateCat con el nuevo valor', () => {
|
||||
const rule = makeRule('r1', { '!=': [{ var: 'at_acabado' }, 'lujo'] }, [
|
||||
{ type: 'domain', target: [], value: 'piel', state: 'forbidden' },
|
||||
]);
|
||||
const catOriginal = makeCat('ct_cars', [makeObj('ob_bmw', [
|
||||
makeAtt('at_acabado', 'standard'),
|
||||
makeAtt('at_tapiceria', 'tela', [rule]),
|
||||
])]);
|
||||
|
||||
const { graph, prev } = setup(catOriginal);
|
||||
|
||||
// Incremental: cambiar acabado a lujo
|
||||
const incremental = evaluateIncremental(
|
||||
catOriginal, graph, prev,
|
||||
{ path: ap('ct_cars', 'ob_bmw', 'at_acabado'), value: 'lujo' },
|
||||
);
|
||||
|
||||
// Full: cat con acabado=lujo desde el principio
|
||||
const catNew = makeCat('ct_cars', [makeObj('ob_bmw', [
|
||||
makeAtt('at_acabado', 'lujo'),
|
||||
makeAtt('at_tapiceria', 'tela', [rule]),
|
||||
])]);
|
||||
const full = evaluateCat(catNew);
|
||||
|
||||
// El effectiveState de los atts afectados debe coincidir
|
||||
const tapInc = incremental.effectiveState.get('ct_cars/ob_bmw/at_tapiceria');
|
||||
const tapFull = full.effectiveState.get('ct_cars/ob_bmw/at_tapiceria');
|
||||
expect(tapInc?.forbiddenValues).toEqual(tapFull?.forbiddenValues);
|
||||
expect(tapInc?.state).toEqual(tapFull?.state);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,284 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createSession } from '../session';
|
||||
import type { Att, Sec, Obj, Cat , Rule } from '../types';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FIXTURES
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeAtt(id: string, value: unknown = 'x', rules?: Rule[]): Att {
|
||||
return {
|
||||
id: id as Att['id'],
|
||||
name: id,
|
||||
value: { domain: { kind: 'string', mode: 'input' }, value: value as string },
|
||||
rules: rules ? { rules } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSec(id: string, atts: Att[], rules?: Rule[]): Sec {
|
||||
return { id: id as Sec['id'], name: id, atts, rules: rules ? { rules } : undefined };
|
||||
}
|
||||
|
||||
function makeObj(id: string, atts: Att[], secs: Sec[] = [], rules?: Rule[]): Obj {
|
||||
return { id: id as Obj['id'], name: id, atts, secs, rules: rules ? { rules } : undefined };
|
||||
}
|
||||
|
||||
function makeCat(id: string, objs: Obj[], atts: Att[] = []): Cat {
|
||||
return { id: id as Cat['id'], name: id, atts, objs };
|
||||
}
|
||||
|
||||
function makeRule(id: string, condition: unknown, actions: Rule['actions'], priority = 10): Rule {
|
||||
return { id: `rl_${id}` as Rule['id'], priority, condition: condition as Rule['condition'], actions };
|
||||
}
|
||||
|
||||
function ap(...parts: string[]) { return parts as any; }
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// createSession
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createSession', () => {
|
||||
it('crea una sesión con el estado inicial evaluado', () => {
|
||||
const obj = makeObj('ob_bmw', [makeAtt('at_color', 'rojo')]);
|
||||
const cat = makeCat('ct_cars', [obj]);
|
||||
const session = createSession(cat);
|
||||
|
||||
expect(session.getResult()).toBeDefined();
|
||||
expect(session.isValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('lanza DependencyCycleError si el cat tiene ciclos', () => {
|
||||
const rAtoB = makeRule('r-a-b', { '==': [{ var: 'at_a' }, 'x'] }, [
|
||||
{ type: 'attribute', target: ['at_b' as Att['id']], state: 'hidden' },
|
||||
]);
|
||||
const rBtoA = makeRule('r-b-a', { '==': [{ var: 'at_b' }, 'y'] }, [
|
||||
{ type: 'attribute', target: ['at_a' as Att['id']], state: 'hidden' },
|
||||
]);
|
||||
const obj = makeObj('ob_obj', [
|
||||
makeAtt('at_a', 'x', [rBtoA]),
|
||||
makeAtt('at_b', 'y', [rAtoB]),
|
||||
]);
|
||||
expect(() => createSession(makeCat('ct_cat', [obj]))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// getValue
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getValue', () => {
|
||||
it('devuelve el valor original del att', () => {
|
||||
const obj = makeObj('ob_bmw', [makeAtt('at_color', 'rojo')]);
|
||||
const session = createSession(makeCat('ct_cars', [obj]));
|
||||
|
||||
expect(session.getValue(ap('ct_cars', 'ob_bmw', 'at_color'))).toBe('rojo');
|
||||
});
|
||||
|
||||
it('devuelve el valor derivado si existe', () => {
|
||||
const rule = makeRule('r1', { '==': [{ var: 'at_motor' }, 'electrico'] }, [
|
||||
{ type: 'value', target: ['at_emisiones' as Att['id']], value: {
|
||||
domain: { kind: 'string', mode: 'input' }, value: '0',
|
||||
}},
|
||||
]);
|
||||
const obj = makeObj('ob_bmw', [
|
||||
makeAtt('at_motor', 'electrico'),
|
||||
makeAtt('at_emisiones', '120', [rule]),
|
||||
]);
|
||||
const session = createSession(makeCat('ct_cars', [obj]));
|
||||
|
||||
// motor=electrico → emisiones derivado a '0'
|
||||
expect(session.getValue(ap('ct_cars', 'ob_bmw', 'at_emisiones'))).toBe('0');
|
||||
});
|
||||
|
||||
it('devuelve el valor actualizado tras setValue', () => {
|
||||
const obj = makeObj('ob_bmw', [makeAtt('at_color', 'rojo')]);
|
||||
const session = createSession(makeCat('ct_cars', [obj]));
|
||||
|
||||
session.setValue(ap('ct_cars', 'ob_bmw', 'at_color'), 'azul');
|
||||
// getValue lee del ConfigState actual — el cat original sigue con 'rojo'
|
||||
// pero el estado de sesión refleja 'azul'
|
||||
expect(session.getValue(ap('ct_cars', 'ob_bmw', 'at_color'))).toBe('rojo'); // cat inmutable
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// getEffectiveState
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getEffectiveState', () => {
|
||||
it('devuelve available por defecto', () => {
|
||||
const obj = makeObj('ob_bmw', [makeAtt('at_color', 'rojo')]);
|
||||
const session = createSession(makeCat('ct_cars', [obj]));
|
||||
|
||||
expect(session.getEffectiveState(ap('ct_cars', 'ob_bmw', 'at_color'))?.state)
|
||||
.toBe('available');
|
||||
});
|
||||
|
||||
it('refleja el estado tras setValue', () => {
|
||||
const rule = makeRule('r1', { '==': [{ var: 'at_acabado' }, 'lujo'] }, [
|
||||
{ type: 'attribute', target: [], state: 'required' },
|
||||
]);
|
||||
const obj = makeObj('ob_bmw', [
|
||||
makeAtt('at_acabado', 'standard'),
|
||||
makeAtt('at_color', 'rojo', [rule]),
|
||||
]);
|
||||
const session = createSession(makeCat('ct_cars', [obj]));
|
||||
|
||||
expect(session.getEffectiveState(ap('ct_cars', 'ob_bmw', 'at_color'))?.state)
|
||||
.toBe('available');
|
||||
|
||||
session.setValue(ap('ct_cars', 'ob_bmw', 'at_acabado'), 'lujo');
|
||||
|
||||
expect(session.getEffectiveState(ap('ct_cars', 'ob_bmw', 'at_color'))?.state)
|
||||
.toBe('required');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// isValid / getErrors / getWarnings
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('isValid / getErrors / getWarnings', () => {
|
||||
it('isValid es true con configuración válida', () => {
|
||||
const obj = makeObj('ob_bmw', [makeAtt('at_color', 'rojo')]);
|
||||
const session = createSession(makeCat('ct_cars', [obj]));
|
||||
expect(session.isValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('isValid es false cuando hay errores', () => {
|
||||
const rule = makeRule('r1', { '!=': [{ var: 'at_acabado' }, 'lujo'] }, [
|
||||
{ type: 'domain', target: [], value: 'piel', state: 'forbidden' },
|
||||
]);
|
||||
const obj = makeObj('ob_bmw', [
|
||||
makeAtt('at_acabado', 'standard'),
|
||||
makeAtt('at_tapiceria', 'piel', [rule]),
|
||||
]);
|
||||
const session = createSession(makeCat('ct_cars', [obj]));
|
||||
expect(session.isValid()).toBe(false);
|
||||
expect(session.getErrors()).toContain('ct_cars/ob_bmw/at_tapiceria');
|
||||
});
|
||||
|
||||
it('getWarnings devuelve paths con warnings', () => {
|
||||
const rule = makeRule('r1', { '==': [{ var: 'at_color' }, 'rojo'] }, [
|
||||
{ type: 'validation', level: 'warning', message: 'Color llamativo' },
|
||||
]);
|
||||
const obj = makeObj('ob_bmw', [makeAtt('at_color', 'rojo', [rule])]);
|
||||
const session = createSession(makeCat('ct_cars', [obj]));
|
||||
expect(session.getWarnings()).toContain('ct_cars/ob_bmw/at_color');
|
||||
expect(session.isValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('los errores se actualizan tras setValue', () => {
|
||||
const rule = makeRule('r1', { '!=': [{ var: 'at_acabado' }, 'lujo'] }, [
|
||||
{ type: 'domain', target: [], value: 'piel', state: 'forbidden' },
|
||||
]);
|
||||
const obj = makeObj('ob_bmw', [
|
||||
makeAtt('at_acabado', 'standard'),
|
||||
makeAtt('at_tapiceria', 'piel', [rule]),
|
||||
]);
|
||||
const session = createSession(makeCat('ct_cars', [obj]));
|
||||
|
||||
expect(session.isValid()).toBe(false);
|
||||
|
||||
session.setValue(ap('ct_cars', 'ob_bmw', 'at_acabado'), 'lujo');
|
||||
|
||||
expect(session.isValid()).toBe(true);
|
||||
expect(session.getErrors()).not.toContain('ct_cars/ob_bmw/at_tapiceria');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// reset
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('reset', () => {
|
||||
it('vuelve al estado inicial tras varios setValue', () => {
|
||||
const rule = makeRule('r1', { '==': [{ var: 'at_acabado' }, 'lujo'] }, [
|
||||
{ type: 'attribute', target: [], state: 'required' },
|
||||
]);
|
||||
const obj = makeObj('ob_bmw', [
|
||||
makeAtt('at_acabado', 'standard'),
|
||||
makeAtt('at_color', 'rojo', [rule]),
|
||||
]);
|
||||
const session = createSession(makeCat('ct_cars', [obj]));
|
||||
|
||||
session.setValue(ap('ct_cars', 'ob_bmw', 'at_acabado'), 'lujo');
|
||||
expect(session.getEffectiveState(ap('ct_cars', 'ob_bmw', 'at_color'))?.state)
|
||||
.toBe('required');
|
||||
|
||||
session.reset();
|
||||
expect(session.getEffectiveState(ap('ct_cars', 'ob_bmw', 'at_color'))?.state)
|
||||
.toBe('available');
|
||||
expect(session.isValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('reset notifica a los listeners', () => {
|
||||
const obj = makeObj('ob_bmw', [makeAtt('at_color', 'rojo')]);
|
||||
const session = createSession(makeCat('ct_cars', [obj]));
|
||||
const listener = vi.fn();
|
||||
|
||||
session.subscribe(listener);
|
||||
session.reset();
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// subscribe / unsubscribe
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('subscribe', () => {
|
||||
it('notifica al listener tras setValue', () => {
|
||||
const obj = makeObj('ob_bmw', [makeAtt('at_color', 'rojo')]);
|
||||
const session = createSession(makeCat('ct_cars', [obj]));
|
||||
const listener = vi.fn();
|
||||
|
||||
session.subscribe(listener);
|
||||
session.setValue(ap('ct_cars', 'ob_bmw', 'at_color'), 'azul');
|
||||
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(listener).toHaveBeenCalledWith(session.getResult());
|
||||
});
|
||||
|
||||
it('no notifica tras cancelar la suscripción', () => {
|
||||
const obj = makeObj('ob_bmw', [makeAtt('at_color', 'rojo')]);
|
||||
const session = createSession(makeCat('ct_cars', [obj]));
|
||||
const listener = vi.fn();
|
||||
|
||||
const unsub = session.subscribe(listener);
|
||||
unsub();
|
||||
session.setValue(ap('ct_cars', 'ob_bmw', 'at_color'), 'azul');
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('múltiples listeners reciben la notificación', () => {
|
||||
const obj = makeObj('ob_bmw', [makeAtt('at_color', 'rojo')]);
|
||||
const session = createSession(makeCat('ct_cars', [obj]));
|
||||
const l1 = vi.fn();
|
||||
const l2 = vi.fn();
|
||||
|
||||
session.subscribe(l1);
|
||||
session.subscribe(l2);
|
||||
session.setValue(ap('ct_cars', 'ob_bmw', 'at_color'), 'azul');
|
||||
|
||||
expect(l1).toHaveBeenCalledOnce();
|
||||
expect(l2).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('cancelar un listener no afecta a los demás', () => {
|
||||
const obj = makeObj('ob_bmw', [makeAtt('at_color', 'rojo')]);
|
||||
const session = createSession(makeCat('ct_cars', [obj]));
|
||||
const l1 = vi.fn();
|
||||
const l2 = vi.fn();
|
||||
|
||||
const unsub1 = session.subscribe(l1);
|
||||
session.subscribe(l2);
|
||||
unsub1();
|
||||
session.setValue(ap('ct_cars', 'ob_bmw', 'at_color'), 'azul');
|
||||
|
||||
expect(l1).not.toHaveBeenCalled();
|
||||
expect(l2).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,15 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// TYPES
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type {AttPath} from "@/vcen/types/att.ts";
|
||||
|
||||
/**
|
||||
* Un cambio de valor pendiente de aplicar.
|
||||
*/
|
||||
export interface AttChange {
|
||||
path: AttPath;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// TYPES
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type {EvaluationResult} from "@/vcen/types/rule.ts";
|
||||
|
||||
export type SessionListener = (result: EvaluationResult) => void;
|
||||
|
||||
Loading…
Reference in new issue