dev 7 months ago
parent c3551e9751
commit bde12d5988

@ -3,5 +3,5 @@
export * from './dates.ts';
export * from './ids.ts';
export * from './json_logic.ts';

@ -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;
}

@ -1,6 +1,28 @@
import type {RelativePath} from "@/vcen/types/rule.ts";
import {JSON_LOGIC_PATH_SEPARATOR} from "@/jslg";
import type {AttID, AttKey, AttPath, CatID, ObjID, SecID} from "@/vcen/types";
import {
type Att,
type AttID, attIDPrefix,
type AttKey,
type AttPath,
type Cat,
type CatID,
catIDPrefix,
type ObjID, objIDPrefix, type OptID, optIDPrefix,
type SecID, secIDPrefix
} from "@/vcen/types";
// ─────────────────────────────────────────────────────────────────────────────
// IDS HELPERS
// ─────────────────────────────────────────────────────────────────────────────
export function MakeCatID (catId : string) : CatID { return `${catIDPrefix}_${catId}` as CatID; }
export function MakeObjID (objId : string) : ObjID { return `${objIDPrefix}_${objId}` as ObjID; }
export function MakeSecID (secId : string) : SecID { return `${secIDPrefix}_${secId}` as SecID; }
export function MakeAttID (attId : string) : AttID { return `${attIDPrefix}_${attId}` as AttID; }
export function MakeOptID (optId : string) : OptID { return `${optIDPrefix}_${optId}` as OptID; }
@ -13,8 +35,6 @@ import type {AttID, AttKey, AttPath, CatID, ObjID, SecID} from "@/vcen/types";
export const nodeSeparator = JSON_LOGIC_PATH_SEPARATOR;
export function parseAttPath(key: string): AttPath {
const parts = key.split(nodeSeparator);
if (parts.length === 2) return parts as [CatID, AttID];
@ -51,3 +71,33 @@ export function resolveRelativePath(prefix: string, path: RelativePath): string
/**
* Construye el estado plano `ConfigState` a partir de un `Cat`.
* Key = path serializado del att, Value = valor actual.
*/
export function buildConfigState(cat: Cat): Record<string, unknown> {
const state: Record<string, unknown> = {};
const addAtt = (prefix: string, att: Att) => {
state[joinPath(prefix, att.id)] = att.value.value;
};
for (const att of cat.atts) {
addAtt(cat.id, att);
}
for (const obj of cat.objs) {
const objPrefix = joinPath(cat.id, obj.id);
for (const att of obj.atts) {
addAtt(objPrefix, att);
}
for (const sec of obj.secs) {
const secPrefix = joinPath(objPrefix, sec.id);
for (const att of sec.atts) {
addAtt(secPrefix, att);
}
}
}
return state;
}

@ -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;
}

@ -4,6 +4,7 @@
export * from './att';
export * from './cat';
export * from './graph';
export * from './incremental';
export * from './node';
export * from './obj';
export * from './rule';

@ -2,15 +2,25 @@ import type {ID, ISODate, ISODatetime, ISOTime} from "@/tsat/types";
export const catIDPrefix = 'ct' as const ;
export const objIDPrefix = 'ob' as const;
export const secIDPrefix = 'sc' as const;
export const attIDPrefix = 'at' as const;
export const optIDPrefix = 'op' as const;
// ─────────────────────────────────────────────────────────────────────────────
// IDENTITY
// ─────────────────────────────────────────────────────────────────────────────
export type CatID = ID<'ct'>;
export type ObjID = ID<'ob'>;
export type SecID = ID<'sc'>;
export type AttID = ID<'at'>;
export type OptID = ID<'op'>;
export type CatID = ID<typeof catIDPrefix>;
export type ObjID = ID<typeof objIDPrefix>;
export type SecID = ID<typeof secIDPrefix>;
export type AttID = ID<typeof attIDPrefix>;
export type OptID = ID<typeof optIDPrefix>;
/** Cualquier entidad que puede participar en el grafo o en un path */
export type NodeID = CatID | ObjID | SecID | AttID;

@ -0,0 +1,8 @@
// ─────────────────────────────────────────────────────────────────────────────
// TYPES
// ─────────────────────────────────────────────────────────────────────────────
import type {EvaluationResult} from "@/vcen/types/rule.ts";
export type SessionListener = (result: EvaluationResult) => void;
Loading…
Cancel
Save

Powered by TurnKey Linux.