diff --git a/src/tsat/types/index.ts b/src/tsat/types/index.ts index e6201cc..9e3f898 100644 --- a/src/tsat/types/index.ts +++ b/src/tsat/types/index.ts @@ -3,5 +3,5 @@ export * from './dates.ts'; export * from './ids.ts'; -export * from './json_logic.ts'; + diff --git a/src/tsat/types/json_logic.ts b/src/tsat/types/json_logic.ts deleted file mode 100644 index d9ea0ba..0000000 --- a/src/tsat/types/json_logic.ts +++ /dev/null @@ -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) => unknown - diff --git a/src/vcen/incremental.ts b/src/vcen/incremental.ts new file mode 100644 index 0000000..080bc61 --- /dev/null +++ b/src/vcen/incremental.ts @@ -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([ + changedKey, + ...transitivelyAffected.map(serializeAttPath), + ]); + + // ── 3. Identificar Objs que contienen atts afectados ───────────────────── + const affectedObjKeys = new Set(); + 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 { + 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; +} diff --git a/src/vcen/node_util.ts b/src/vcen/node_util.ts index 033b589..b48ad35 100644 --- a/src/vcen/node_util.ts +++ b/src/vcen/node_util.ts @@ -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 { + const state: Record = {}; + + 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; +} \ No newline at end of file diff --git a/src/vcen/session.ts b/src/vcen/session.ts new file mode 100644 index 0000000..88c16e6 --- /dev/null +++ b/src/vcen/session.ts @@ -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 = 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); +} \ No newline at end of file diff --git a/src/vcen/test/incremental.test.ts b/src/vcen/test/incremental.test.ts new file mode 100644 index 0000000..5cbf324 --- /dev/null +++ b/src/vcen/test/incremental.test.ts @@ -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); + }); +}); diff --git a/src/vcen/test/session.test.ts b/src/vcen/test/session.test.ts new file mode 100644 index 0000000..22dce95 --- /dev/null +++ b/src/vcen/test/session.test.ts @@ -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(); + }); +}); diff --git a/src/vcen/types/incremental.ts b/src/vcen/types/incremental.ts new file mode 100644 index 0000000..aac857d --- /dev/null +++ b/src/vcen/types/incremental.ts @@ -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; +} + + diff --git a/src/vcen/types/index.ts b/src/vcen/types/index.ts index c9188e8..dac4260 100644 --- a/src/vcen/types/index.ts +++ b/src/vcen/types/index.ts @@ -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'; diff --git a/src/vcen/types/node.ts b/src/vcen/types/node.ts index 075dd3a..02a4010 100644 --- a/src/vcen/types/node.ts +++ b/src/vcen/types/node.ts @@ -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; +export type ObjID = ID; +export type SecID = ID; +export type AttID = ID; +export type OptID = ID; + /** Cualquier entidad que puede participar en el grafo o en un path */ export type NodeID = CatID | ObjID | SecID | AttID; diff --git a/src/vcen/types/session.ts b/src/vcen/types/session.ts new file mode 100644 index 0000000..1d937bb --- /dev/null +++ b/src/vcen/types/session.ts @@ -0,0 +1,8 @@ +// ───────────────────────────────────────────────────────────────────────────── +// TYPES +// ───────────────────────────────────────────────────────────────────────────── + +import type {EvaluationResult} from "@/vcen/types/rule.ts"; + +export type SessionListener = (result: EvaluationResult) => void; +