You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

354 lines
13 KiB

import type {GlobInstance} from "@/glob/lib";
// GlobInstance puede ser null cuando la sesión se usa solo para evaluación
// (sin pricing ni view resolution)
type GlobOrNull = GlobInstance | null;
import { JsonLogicEngine } from '@/jslg/lib';
import type {
Cat,
AttEffectiveState,
EvaluationResult,
AttPath,
DependencyGraph,
SessionListener, ObjPriceResult, View, Obj, Visual, ViewResolution, ConfigState
} from './types';
import {keyToAttPath, serializeAttPath} from './node_util.ts';
import { buildDependencyGraph } from './graph.ts';
import {buildConfigState, evaluateCat} from './engine.ts';
import { evaluateIncremental } from './incremental.ts';
import {computeObjPrice} from "@/vcen/lib/pricing.ts";
import {
resolveView as resolveViewFn,
resolveVisual as resolveVisualFn,
resolveDefaultView as resolveDefaultViewFn
} from './view';
// ─────────────────────────────────────────────────────────────────────────────
// SESSION
// ─────────────────────────────────────────────────────────────────────────────
/**
* Sesión de configuración en runtime.
*
* Encapsula el `Cat`, el `DependencyGraph`, el `EvaluationResult` actual,
* el motor de pricing y el motor visual.
*
* ## Ciclo de vida
*
* ```
* const session = createSession(cat, glob);
* session.setValue(path, value); // el usuario cambia un valor
* session.getValue(path); // leer valor actual
* session.getEffectiveState(path); // leer estado efectivo
* session.getPrice('ob:bmw430'); // precio del objeto configurado
* session.resolveDefaultView(visual, obj); // vista activa del objeto
* 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 glob: GlobOrNull;
private readonly initialResult: EvaluationResult;
private current: EvaluationResult;
private readonly listeners: Set<SessionListener> = new Set();
private configState: ConfigState;
private readonly userValues: Map<string, unknown> = new Map();
constructor(
cat: Cat,
glob: GlobOrNull = null,
engine: JsonLogicEngine | null = null,
) {
this.cat = cat;
this.glob = glob;
this.engine = engine ?? new JsonLogicEngine();
this.graph = buildDependencyGraph(cat, this.engine);
const initial = evaluateCat(cat, this.engine);
this.initialResult = initial;
this.current = initial;
this.configState = buildConfigState(cat);
}
// ── 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;
}
// ── Pricing ───────────────────────────────────────────────────────────────
/**
* Calcula el precio de un `Obj` concreto en el estado actual de la sesión.
*
* Usa el `ConfigState` actual (con los valores modificados por el usuario)
* y la instancia `glob` para resolver `LingString` y formatear moneda.
*
* @param objId - ID del objeto a calcular
* @returns `ObjPriceResult` o `undefined` si el objId no existe en el Cat
*/
getPrice(objId: string): ObjPriceResult | undefined {
if (!this.glob) return undefined;
const obj = this.cat.objs.find(o => o.id === objId);
if (!obj) return undefined;
return computeObjPrice(
obj,
this.cat.opts,
this.cat.pricingDefaults,
this.configState,
this.glob,
this.engine,
);
}
/**
* Calcula el precio de todos los `Obj` del Cat en el estado actual.
*
* @returns Array de `ObjPriceResult`, uno por cada Obj del Cat
*/
getAllPrices(): ObjPriceResult[] {
if (!this.glob) return [];
return this.cat.objs.map(obj =>
computeObjPrice(obj, this.cat.opts, this.cat.pricingDefaults, this.configState, this.glob!, this.engine)
);
}
// ── Visual ────────────────────────────────────────────────────────────────
/**
* Resuelve una `View` concreta para un `Obj`.
*
* Aplica la cadena de herencia de `basePath` y `fallback`:
* ViewConfig → secVisual → objVisual → Cat.visualDefaults
*
* @param view - Vista a resolver
* @param obj - Objeto configurado
* @param secVisual - Visual de la Sec que contiene la vista (si aplica)
* @param objVisual - Visual del Obj (si aplica)
*/
resolveView(
view: View,
obj: Obj,
secVisual?: Visual,
objVisual?: Visual,
): ViewResolution | undefined {
if (!this.glob) return undefined;
return resolveViewFn(
view, obj, secVisual, objVisual, this.cat, this.configState, this.engine,
);
}
/**
* Resuelve todas las vistas de un `Visual` para un `Obj`.
*
* @param visual - Visual a resolver (de un Obj o Sec)
* @param obj - Objeto configurado
* @param secVisual - Visual de la Sec (si aplica)
* @param objVisual - Visual del Obj (si aplica)
*/
resolveVisual(
visual: Visual,
obj: Obj,
secVisual?: Visual,
objVisual?: Visual,
): ViewResolution[] {
if (!this.glob) return [];
return resolveVisualFn(
visual, obj, secVisual, objVisual, this.cat, this.configState, this.engine,
);
}
/**
* Resuelve la vista activa por defecto de un `Visual`.
* Usa `defaultViewId` si está definido; si no, la primera vista.
*
* @returns `ViewResolution` o `undefined` si el Visual no tiene vistas
*/
resolveDefaultView(
visual: Visual,
obj: Obj,
secVisual?: Visual,
objVisual?: Visual,
): ViewResolution | undefined {
if (!this.glob) return undefined;
return resolveDefaultViewFn(
visual, obj, secVisual, objVisual, this.cat, this.configState, this.engine,
);
}
// ── 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 {
this.userValues.set(serializeAttPath(path), value);
const next = evaluateIncremental(
this.cat,
this.graph,
this.current,
{ path, value },
this.engine,
);
this.current = next;
this.configState = this.buildCurrentConfigState();
this.notify();
return next;
}
/**
* Vuelve al estado inicial (valores del Cat original, evaluación completa).
* Notifica a todos los listeners.
*/
reset(): EvaluationResult {
this.userValues.clear();
this.current = this.initialResult;
this.configState = buildConfigState(this.cat);
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));
* unsub();
*/
subscribe(listener: SessionListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
// ── Internals ─────────────────────────────────────────────────────────────
/**
* Reconstruye el ConfigState a partir de:
* 1. Valores base del Cat
* 2. Valores establecidos por el usuario (userValues)
* 3. Valores derivados del resultado actual (derivedValues — máxima prioridad)
*/
private buildCurrentConfigState(): ConfigState {
const state = buildConfigState(this.cat);
for (const [key, value] of this.userValues) {
state[key] = value;
}
for (const [key, es] of this.current.effectiveState) {
if (es.derivedValue !== undefined) {
state[key] = es.derivedValue.value;
}
}
return state;
}
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.
*
* @param cat - Catálogo a configurar
* @param glob - Instancia de GlobInstance para pricing y localización
* @param engine - Instancia JsonLogicEngine (null = nueva interna)
*
* @throws DependencyCycleError si el Cat tiene ciclos en sus reglas
*
* @example
* const session = createSession(cat, glob);
* session.setValue(['ct:cars', 'ob:bmw', 'at:acabado'], 'lujo');
* session.getPrice('ob:bmw');
* session.isValid();
*/
export function createSession(
cat: Cat,
glob: GlobInstance | null = null,
engine: JsonLogicEngine | null = null,
): Session {
return new Session(cat, glob, engine);
}

Powered by TurnKey Linux.