diff --git a/src/vcen/editor/App.svelte b/src/vcen/editor/App.svelte index 9dddf9c..7463da9 100644 --- a/src/vcen/editor/App.svelte +++ b/src/vcen/editor/App.svelte @@ -1,14 +1,15 @@
- +
diff --git a/src/vcen/editor/canvas/Editor.svelte b/src/vcen/editor/canvas/Editor.svelte new file mode 100644 index 0000000..174f18a --- /dev/null +++ b/src/vcen/editor/canvas/Editor.svelte @@ -0,0 +1,204 @@ + + + +
+ +
+ + + \ No newline at end of file diff --git a/src/vcen/editor/comps/AttPanel.svelte b/src/vcen/editor/comps/AttPanel.svelte new file mode 100644 index 0000000..cf7cde0 --- /dev/null +++ b/src/vcen/editor/comps/AttPanel.svelte @@ -0,0 +1,365 @@ + + +{#if !context} +
+ Selecciona un atributo +
+{:else} + {@const { att, objId, secId, attId } = context} + +
+ + +
+
+ Atributo + {att.name} +
+ +
+ + +
+ + +
+ + { if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur(); }} + class="field-input" + /> +
+ + +
+ + +
+ + + {#if att.value.domain.kind === 'string'} + {@const d = att.value.domain as any} +
+ + + {#if d.mode === 'list'} + + {:else if d.mode === 'pattern'} + editor.updateAttDomain(objId, secId, attId, + { kind: 'string', mode: 'pattern', pattern: e.currentTarget.value })} + placeholder="^[A-Z]{2}[0-9]+$" class="field-input" /> + {:else if d.mode === 'fixed'} + editor.updateAttDomain(objId, secId, attId, + { kind: 'string', mode: 'fixed', fixed: e.currentTarget.value })} + class="field-input" /> + {/if} +
+ + {:else if att.value.domain.kind === 'number' || att.value.domain.kind === 'currency'} + {@const d = att.value.domain as any} +
+ + + {#if d.mode === 'range' || d.mode === 'step'} +
+
+ Mín + editor.updateAttDomain(objId, secId, attId, { ...d, min: Number(e.currentTarget.value) })} + class="field-input" /> +
+
+ Máx + editor.updateAttDomain(objId, secId, attId, { ...d, max: Number(e.currentTarget.value) })} + class="field-input" /> +
+
+ {#if d.mode === 'step'} +
+ Paso + editor.updateAttDomain(objId, secId, attId, { ...d, step: Number(e.currentTarget.value) })} + class="field-input" /> +
+ {/if} + {:else if d.mode === 'fixed'} + editor.updateAttDomain(objId, secId, attId, + { kind: att.value.domain.kind, mode: 'fixed', fixed: Number(e.currentTarget.value) } as any)} + class="field-input" /> + {/if} +
+ + {:else if att.value.domain.kind === 'option'} +
+ + Opciones disponibles + + ({attOptIds.size} de {availableOpts.length}) + + + {#if availableOpts.length === 0} +

+ No hay opciones globales — créalas en "Opciones globales" +

+ {:else} +
+ {#each availableOpts as opt (opt.id)} + {@const checked = attOptIds.has(opt.id)} + + {/each} +
+ {/if} +
+ {/if} + + +
+ + + {#if att.value.domain.kind === 'boolean'} + + + {:else if att.value.domain.kind === 'option'} + {@const d = att.value.domain as any} + + + {:else if att.value.domain.kind === 'number' || att.value.domain.kind === 'currency'} + saveDefault(e.currentTarget.value)} + class="field-input" /> + + {:else if att.value.domain.kind === 'date'} + saveDefault(e.currentTarget.value)} + class="field-input" /> + + {:else if att.value.domain.kind === 'time'} + saveDefault(e.currentTarget.value)} + class="field-input" /> + + {:else if att.value.domain.kind === 'datetime'} + saveDefault(e.currentTarget.value)} + class="field-input" /> + + {:else} + saveDefault(e.currentTarget.value)} + class="field-input" /> + {/if} +
+ + +
+
+ Reglas + {#if ruleSet} + + {:else} + + {/if} +
+ {#if ruleSet && context.container} + + {/if} +
+ +
+
+{/if} + + diff --git a/src/vcen/editor/comps/CatPanel.svelte b/src/vcen/editor/comps/CatPanel.svelte new file mode 100644 index 0000000..3387cf2 --- /dev/null +++ b/src/vcen/editor/comps/CatPanel.svelte @@ -0,0 +1,59 @@ + + +
+ +

Catálogo

+ +
+ + +
+ +
+ Resumen +
+
+ {cat.objs.length} + Objetos +
+
+ + {cat.objs.reduce((n, o) => n + o.secs.length, 0)} + + Secciones +
+
+ + {cat.objs.reduce((n, o) => + n + o.atts.length + o.secs.reduce((m, s) => m + s.atts.length, 0), 0 + )} + + Atributos +
+
+ {cat.opts.length} + Opciones globales +
+
+
+ +
diff --git a/src/vcen/editor/comps/CatalogEditor.svelte b/src/vcen/editor/comps/CatalogEditor.svelte new file mode 100644 index 0000000..0584df6 --- /dev/null +++ b/src/vcen/editor/comps/CatalogEditor.svelte @@ -0,0 +1,192 @@ + + +
+ + +
+ +
+ + +
+ + +
+ + {#if sel.kind === 'cat'} Catálogo + {:else if sel.kind === 'opts'} Opciones globales + {:else if sel.kind === 'obj'} Objeto + {:else if sel.kind === 'sec'} Sección + {:else if sel.kind === 'att'} Atributo + {/if} + +
+ {#if oncancel} + + {/if} + +
+
+ + +
+ {#if sel.kind === 'cat'} + {:else if sel.kind === 'opts'} + {:else if sel.kind === 'obj'} + {:else if sel.kind === 'sec'} + {:else if sel.kind === 'att'} + {/if} +
+ +
+ +
+ + diff --git a/src/vcen/editor/comps/CatalogTree.svelte b/src/vcen/editor/comps/CatalogTree.svelte new file mode 100644 index 0000000..f7318b3 --- /dev/null +++ b/src/vcen/editor/comps/CatalogTree.svelte @@ -0,0 +1,268 @@ + + + + + + + \ No newline at end of file diff --git a/src/vcen/editor/comps/CompareNodeEditor.svelte b/src/vcen/editor/comps/CompareNodeEditor.svelte index c8a158c..fca632f 100644 --- a/src/vcen/editor/comps/CompareNodeEditor.svelte +++ b/src/vcen/editor/comps/CompareNodeEditor.svelte @@ -1,6 +1,6 @@ + +{#if !obj || sel.kind !== 'obj'} +
+ Selecciona un objeto +
+{:else} +
+ + +
+

{obj.name}

+ +
+ + +
+ + +
+ + +
+ Estructura +
+ + {obj.atts.length} atts directos + + + {obj.secs.length} secciones + + + + {obj.secs.reduce((n, s) => n + s.atts.length, 0)} + atts en secciones + +
+
+ + +
+ Posición +
+ + +
+
+ + +
+
+ Reglas + {#if obj.rules} + + {:else} + + {/if} +
+ {#if obj.rules} + + {/if} +
+ +
+{/if} diff --git a/src/vcen/editor/comps/OptPanel.svelte b/src/vcen/editor/comps/OptPanel.svelte new file mode 100644 index 0000000..7dde74a --- /dev/null +++ b/src/vcen/editor/comps/OptPanel.svelte @@ -0,0 +1,246 @@ + + +
+ + +
+
+ Categorías +
+ + {#if cat.optCategories.length === 0} +

Sin categorías

+ {:else} +
+ {#each cat.optCategories as oc (oc.id)} + {#if editingCatEditId === oc.id} +
+ { if (e.key === 'Enter') saveEditCat(); if (e.key === 'Escape') editingCatEditId = null; }} + class="flex-1 rounded border border-blue-600 bg-gray-900 px-2 py-1 + text-sm text-gray-100 focus:outline-none" + /> + + +
+ {:else} +
+ + {typeof oc.name === 'string' ? oc.name : (oc.name as any)?.['es'] ?? oc.id} + + + {cat.opts.filter(o => o.categoryId === oc.id).length} opts + + + +
+ {/if} + {/each} +
+ {/if} + + +
+ { if (e.key === 'Enter') addCategory(); }} + class="flex-1 rounded border border-gray-700 bg-gray-900 px-3 py-1.5 + text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 + placeholder:text-gray-700" + /> + +
+
+ + +
+
+ Opciones ({cat.opts.length}) + +
+ + {#if cat.opts.length === 0} +

Sin opciones — pulsa "+ Opción" para crear una

+ {:else} +
+ {#each cat.opts as opt (opt.id)} + {#if editingOptId === opt.id} + +
+
+
+ Valor + +
+
+ Etiqueta (opcional) + +
+
+ {#if cat.optCategories.length > 0} +
+ Categoría + +
+ {/if} +
+ + +
+
+ {:else} + +
+ + {String(opt.value.value)} + + {#if opt.label} + + → {typeof opt.label === 'string' ? opt.label : (opt.label as any)?.['es'] ?? ''} + + {/if} + {#if opt.categoryId} + + {cat.optCategories.find(c => c.id === opt.categoryId)?.name ?? opt.categoryId} + + {:else} + + {/if} + + +
+ {/if} + {/each} +
+ {/if} +
+ +
diff --git a/src/vcen/editor/comps/RuleSetEditor.svelte b/src/vcen/editor/comps/RuleSetEditor.svelte new file mode 100644 index 0000000..e4588e3 --- /dev/null +++ b/src/vcen/editor/comps/RuleSetEditor.svelte @@ -0,0 +1,317 @@ + + +
+ + {#if ruleSet.rules.length === 0} +

Sin reglas

+ {/if} + + {#each ruleSet.rules as rule (rule.id)} + {@const cbStore = getCbStore(rule)} + {@const collapsed = collapsedRules.has(rule.id)} + +
+ + +
+ + + P + editor.updateRule(ruleSet, rule.id, { priority: Number(e.currentTarget.value) })} + class="w-12 rounded border border-gray-700 bg-gray-800 px-1.5 py-0.5 + text-xs text-gray-300 focus:outline-none focus:ring-1 focus:ring-indigo-500 shrink-0" + /> + + + {rule.actions.length} acción{rule.actions.length !== 1 ? 'es' : ''} + {#if !cbStore.isValid} + · condición incompleta + {/if} + + + +
+ + {#if !collapsed} +
+ + +
+ Condición +
+ syncCondition(rule)} + /> +
+
+ + +
+
+ Acciones + +
+ + {#if rule.actions.length === 0} +

Sin acciones

+ {/if} + + {#each rule.actions as action, idx (idx)} +
+ + +
+ + {ACTION_KINDS.find(k => k.kind === action.type)?.label ?? action.type} + + setActionLabel(rule.id, idx, e.currentTarget.value, rule)} + placeholder="Descripción opcional..." + class="flex-1 rounded border border-transparent bg-transparent px-1.5 py-0.5 + text-xs text-gray-400 placeholder-gray-700 + hover:border-gray-700 focus:border-gray-600 focus:bg-gray-800 + focus:outline-none transition-colors" + /> + +
+ + + {#if action.type === 'attribute'} + {@const a = action as RuleActionAttribute} +
+
+ Si cumple + +
+
+ Si no cumple + +
+
+
+ Target (vacío = self) + editor.updateAction(ruleSet, rule.id, idx, + { ...a, target: e.currentTarget.value + .split(',').map((s:string) => s.trim()).filter(Boolean) as any })} + placeholder="vacío = el propio att" + class="field-input" /> +
+ + {:else if action.type === 'validation'} + {@const a = action as RuleActionValidation} +
+
+ Nivel + +
+
+ Mensaje + editor.updateAction(ruleSet, rule.id, idx, + { ...a, message: e.currentTarget.value })} + placeholder="Campo obligatorio" + class="field-input" /> +
+
+ + {:else if action.type === 'domain'} + {@const a = action as RuleActionDomain} +
+
+ Estado + +
+
+ Target + editor.updateAction(ruleSet, rule.id, idx, + { ...a, target: e.currentTarget.value + .split(',').map((s:string) => s.trim()).filter(Boolean) as any })} + class="field-input" /> +
+
+ + {:else if action.type === 'value'} + {@const a = action as RuleActionValue} +
+ Valor (JSON) + { + try { + const parsed = JSON.parse(e.currentTarget.value); + editor.updateAction(ruleSet, rule.id, idx, + { ...a, value: { ...a.value, value: parsed } as any }); + } catch { /* JSON inválido, esperar */ } + }} + placeholder='"texto" o 42 o true' + class="field-input" /> +
+ {/if} + +
+ {/each} +
+ +
+ {/if} + +
+ {/each} + + + + +
diff --git a/src/vcen/editor/comps/SecPanel.svelte b/src/vcen/editor/comps/SecPanel.svelte new file mode 100644 index 0000000..19ac874 --- /dev/null +++ b/src/vcen/editor/comps/SecPanel.svelte @@ -0,0 +1,118 @@ + + +{#if !sec || !obj || sel.kind !== 'sec'} +
+ Selecciona una sección +
+{:else} +
+ + +
+
+ {obj.name} +

{sec.name}

+
+ +
+ + +
+ + +
+ + +
+ Estructura +
+ + {sec.atts.length} atributos + +
+
+ + +
+ Posición en "{obj.name}" +
+ + +
+
+ + +
+
+ Reglas + {#if sec.rules} + + {:else} + + {/if} +
+ {#if sec.rules} + + {/if} +
+ +
+{/if} diff --git a/src/vcen/editor/comps/demos/AttEditor.svelte b/src/vcen/editor/demos/AttEditor.svelte similarity index 95% rename from src/vcen/editor/comps/demos/AttEditor.svelte rename to src/vcen/editor/demos/AttEditor.svelte index aab4a6a..994b4bf 100644 --- a/src/vcen/editor/comps/demos/AttEditor.svelte +++ b/src/vcen/editor/demos/AttEditor.svelte @@ -1,8 +1,8 @@ {#if !att} @@ -123,7 +130,7 @@
- {#each (att.value.domain as any).options ?? [] as opt} + {#each (att.value.domain as AttDomainOptions).options ?? [] as opt} {opt} {/each} diff --git a/src/vcen/editor/comps/demos/CatalogDemo.svelte b/src/vcen/editor/demos/CatalogDemo.svelte similarity index 98% rename from src/vcen/editor/comps/demos/CatalogDemo.svelte rename to src/vcen/editor/demos/CatalogDemo.svelte index c3abeef..b16316c 100644 --- a/src/vcen/editor/comps/demos/CatalogDemo.svelte +++ b/src/vcen/editor/demos/CatalogDemo.svelte @@ -1,9 +1,9 @@ + * + * + * ``` + */ +export function createCatalogEditorStore(initial: Cat) { + let cat = $state(structuredClone(initial)); + let selection = $state({ kind: 'cat' }); + let dirty = $state(false); + + // ── Helpers internos ──────────────────────────────────────────────────── + + function mutate(fn: () => void) { + fn(); + dirty = true; + } + + function findObj(objId: ObjID): Obj | undefined { + return cat.objs.find(o => o.id === objId); + } + + function findSec(objId: ObjID, secId: SecID): Sec | undefined { + return findObj(objId)?.secs.find(s => s.id === secId); + } + + function findAtt(objId: ObjID, secId: SecID | undefined, attId: AttID): Att | undefined { + if (secId) return findSec(objId, secId)?.atts.find(a => a.id === attId); + return findObj(objId)?.atts.find(a => a.id === attId); + } + + function findCatAtt(attId: AttID): Att | undefined { + return cat.atts.find(a => a.id === attId); + } + + // ── Selección ─────────────────────────────────────────────────────────── + + function select(s: Selection) { + selection = s; + } + + // ── Cat ───────────────────────────────────────────────────────────────── + + function renameCat(name: string) { + mutate(() => { cat.name = name; }); + } + + // ── Obj ───────────────────────────────────────────────────────────────── + + function addObj(name?: string): ObjID { + const obj = defaultObj(name); + mutate(() => { cat.objs.push(obj); }); + select({ kind: 'obj', objId: obj.id }); + return obj.id; + } + + function renameObj(objId: ObjID, name: string) { + mutate(() => { + const obj = findObj(objId); + if (obj) obj.name = name; + }); + } + + function removeObj(objId: ObjID) { + mutate(() => { + cat.objs = cat.objs.filter(o => o.id !== objId); + }); + // Limpiar selección si apuntaba al obj eliminado + if (selection.kind === 'obj' && selection.objId === objId) select({ kind: 'cat' }); + if ((selection.kind === 'sec' || selection.kind === 'att') && selection.objId === objId) select({ kind: 'cat' }); + } + + function moveObj(objId: ObjID, direction: 'up' | 'down') { + mutate(() => { + const idx = cat.objs.findIndex(o => o.id === objId); + if (idx === -1) return; + const target = direction === 'up' ? idx - 1 : idx + 1; + if (target < 0 || target >= cat.objs.length) return; + [cat.objs[idx], cat.objs[target]] = [cat.objs[target], cat.objs[idx]]; + }); + } + + // ── Sec ───────────────────────────────────────────────────────────────── + + function addSec(objId: ObjID, name?: string): SecID { + const sec = defaultSec(name); + mutate(() => { + const obj = findObj(objId); + if (obj) obj.secs.push(sec); + }); + select({ kind: 'sec', objId, secId: sec.id }); + return sec.id; + } + + function renameSec(objId: ObjID, secId: SecID, name: string) { + mutate(() => { + const sec = findSec(objId, secId); + if (sec) sec.name = name; + }); + } + + function removeSec(objId: ObjID, secId: SecID) { + mutate(() => { + const obj = findObj(objId); + if (obj) obj.secs = obj.secs.filter(s => s.id !== secId); + }); + if (selection.kind === 'sec' && selection.secId === secId) select({ kind: 'obj', objId }); + if (selection.kind === 'att' && selection.secId === secId) select({ kind: 'obj', objId }); + } + + function moveSec(objId: ObjID, secId: SecID, direction: 'up' | 'down') { + mutate(() => { + const obj = findObj(objId); + if (!obj) return; + const idx = obj.secs.findIndex(s => s.id === secId); + if (idx === -1) return; + const target = direction === 'up' ? idx - 1 : idx + 1; + if (target < 0 || target >= obj.secs.length) return; + [obj.secs[idx], obj.secs[target]] = [obj.secs[target], obj.secs[idx]]; + }); + } + + function setSecRuleSet(objId: ObjID, secId: SecID, ruleSet: RuleSet | undefined) { + mutate(() => { + const sec = findSec(objId, secId); + if (sec) sec.rules = ruleSet; + }); + } + + // ── Att (en Obj o Sec) ────────────────────────────────────────────────── + + function addAtt(objId: ObjID, secId: SecID | undefined, name?: string): AttID { + const att = defaultAtt(name); + mutate(() => { + if (secId) { + const sec = findSec(objId, secId); + if (sec) sec.atts.push(att); + } else { + const obj = findObj(objId); + if (obj) obj.atts.push(att); + } + }); + select({ kind: 'att', objId, secId, attId: att.id }); + return att.id; + } + + function addCatAtt(name?: string): AttID { + const att = defaultAtt(name); + mutate(() => { cat.atts.push(att); }); + select({ kind: 'att', objId: '' as ObjID, secId: undefined, attId: att.id }); + return att.id; + } + + function renameAtt(objId: ObjID, secId: SecID | undefined, attId: AttID, name: string) { + mutate(() => { + const att = findAtt(objId, secId, attId); + if (att) att.name = name; + }); + } + + function removeAtt(objId: ObjID, secId: SecID | undefined, attId: AttID) { + mutate(() => { + if (secId) { + const sec = findSec(objId, secId); + if (sec) sec.atts = sec.atts.filter(a => a.id !== attId); + } else { + const obj = findObj(objId); + if (obj) obj.atts = obj.atts.filter(a => a.id !== attId); + } + }); + if (selection.kind === 'att' && selection.attId === attId) { + select(secId ? { kind: 'sec', objId, secId } : { kind: 'obj', objId }); + } + } + + function moveAtt(objId: ObjID, secId: SecID | undefined, attId: AttID, direction: 'up' | 'down') { + mutate(() => { + const list = secId ? findSec(objId, secId)?.atts : findObj(objId)?.atts; + if (!list) return; + const idx = list.findIndex(a => a.id === attId); + if (idx === -1) return; + const target = direction === 'up' ? idx - 1 : idx + 1; + if (target < 0 || target >= list.length) return; + [list[idx], list[target]] = [list[target], list[idx]]; + }); + } + + function updateAttDomain(objId: ObjID, secId: SecID | undefined, attId: AttID, domain: AttDomain) { + mutate(() => { + const att = findAtt(objId, secId, attId); + if (!att) return; + // Cambiar dominio resetea el valor al default del nuevo kind + att.value = { domain, value: defaultValueForDomain(domain) } as AttValue; + }); + } + + function updateAttDefaultValue(objId: ObjID, secId: SecID | undefined, attId: AttID, value: unknown) { + mutate(() => { + const att = findAtt(objId, secId, attId); + if (att) (att.value as any).value = value; + }); + } + + function setAttRuleSet(objId: ObjID, secId: SecID | undefined, attId: AttID, ruleSet: RuleSet | undefined) { + mutate(() => { + const att = findAtt(objId, secId, attId); + if (att) att.rules = ruleSet; + }); + } + + function setObjRuleSet(objId: ObjID, ruleSet: RuleSet | undefined) { + mutate(() => { + const obj = findObj(objId); + if (obj) obj.rules = ruleSet; + }); + } + + // ── Opts globales ─────────────────────────────────────────────────────── + + function addOpt(value = '', categoryId?: OptCatID): OptID { + const opt = defaultOpt(value); + if (categoryId) opt.categoryId = categoryId; + mutate(() => { cat.opts.push(opt); }); + return opt.id; + } + + function updateOpt(optId: OptID, patch: Partial>) { + mutate(() => { + const opt = cat.opts.find(o => o.id === optId); + if (!opt) return; + if (patch.value !== undefined) opt.value = patch.value; + if (patch.label !== undefined) opt.label = patch.label; + if (patch.categoryId !== undefined) opt.categoryId = patch.categoryId; + if (patch.rules !== undefined) opt.rules = patch.rules; + }); + } + + function removeOpt(optId: OptID) { + mutate(() => { + cat.opts = cat.opts.filter(o => o.id !== optId); + // Limpiar referencias en dominios option/list de todos los atts + removeOptFromAllDomains(optId); + }); + } + + function removeOptFromAllDomains(optId: OptID) { + // Cat-level atts + for (const att of cat.atts) pruneAttOpt(att, optId); + // Obj/Sec atts + for (const obj of cat.objs) { + for (const att of obj.atts) pruneAttOpt(att, optId); + for (const sec of obj.secs) { + for (const att of sec.atts) pruneAttOpt(att, optId); + } + } + } + + function pruneAttOpt(att: Att, optId: OptID) { + if (att.value.domain.kind === 'option' && att.value.domain.mode === 'list') { + (att.value.domain as any).list = (att.value.domain as any).list.filter( + (ao: AttOpt) => ao.optId !== optId + ); + } + } + + function addAttOpt(objId: ObjID, secId: SecID | undefined, attId: AttID, optId: OptID) { + mutate(() => { + const att = findAtt(objId, secId, attId); + if (!att || att.value.domain.kind !== 'option' || att.value.domain.mode !== 'list') return; + const list = (att.value.domain as any).list as AttOpt[]; + if (!list.find(ao => ao.optId === optId)) { + list.push({ optId }); + } + }); + } + + function removeAttOpt(objId: ObjID, secId: SecID | undefined, attId: AttID, optId: OptID) { + mutate(() => { + const att = findAtt(objId, secId, attId); + if (!att || att.value.domain.kind !== 'option' || att.value.domain.mode !== 'list') return; + (att.value.domain as any).list = ((att.value.domain as any).list as AttOpt[]) + .filter(ao => ao.optId !== optId); + }); + } + + // ── OptCategories ─────────────────────────────────────────────────────── + + function addOptCategory(name?: string): OptCatID { + const oc = defaultOptCategory(name); + mutate(() => { cat.optCategories.push(oc); }); + return oc.id; + } + + function renameOptCategory(ocId: OptCatID, name: string) { + mutate(() => { + const oc = cat.optCategories.find(c => c.id === ocId); + if (oc) oc.name = name; + }); + } + + function removeOptCategory(ocId: OptCatID) { + mutate(() => { + cat.optCategories = cat.optCategories.filter(c => c.id !== ocId); + // Opts que apuntaban a esta categoría quedan sin categoría + for (const opt of cat.opts) { + if (opt.categoryId === ocId) delete opt.categoryId; + } + }); + } + + // ── RuleSet helpers ───────────────────────────────────────────────────── + + function addRule(ruleSet: RuleSet): RuleID { + const rule = defaultRule(); + mutate(() => { ruleSet.rules.push(rule); }); + return rule.id; + } + + function updateRule(ruleSet: RuleSet, ruleId: RuleID, patch: Partial>) { + mutate(() => { + const rule = ruleSet.rules.find(r => r.id === ruleId); + if (!rule) return; + if (patch.condition !== undefined) rule.condition = patch.condition; + if (patch.priority !== undefined) rule.priority = patch.priority; + }); + } + + function removeRule(ruleSet: RuleSet, ruleId: RuleID) { + mutate(() => { + ruleSet.rules = ruleSet.rules.filter(r => r.id !== ruleId); + }); + } + + function addAction(ruleSet: RuleSet, ruleId: RuleID, action: RuleAction) { + mutate(() => { + const rule = ruleSet.rules.find(r => r.id === ruleId); + if (rule) rule.actions.push(action); + }); + } + + function updateAction(ruleSet: RuleSet, ruleId: RuleID, actionIdx: number, action: RuleAction) { + mutate(() => { + const rule = ruleSet.rules.find(r => r.id === ruleId); + if (rule && actionIdx < rule.actions.length) rule.actions[actionIdx] = action; + }); + } + + function removeAction(ruleSet: RuleSet, ruleId: RuleID, actionIdx: number) { + mutate(() => { + const rule = ruleSet.rules.find(r => r.id === ruleId); + if (rule) rule.actions.splice(actionIdx, 1); + }); + } + + // ── Persistencia ──────────────────────────────────────────────────────── + + /** Devuelve el Cat en estado actual. Útil para guardar. */ + function getCat(): Cat { + return cat; + } + + /** Marca el store como limpio (p.ej. tras guardar). */ + function markClean() { + dirty = false; + } + + /** Descarta todos los cambios y vuelve al Cat inicial. */ + function discard() { + cat = structuredClone(initial); + dirty = false; + selection = { kind: 'cat' }; + } + + // ── API pública ────────────────────────────────────────────────────────── + + return { + // Estado reactivo + get cat() { return cat; }, + get selection() { return selection; }, + get dirty() { return dirty; }, + + // Selección + select, + + // Cat + renameCat, + + // Obj + addObj, renameObj, removeObj, moveObj, + setObjRuleSet, + + // Sec + addSec, renameSec, removeSec, moveSec, + setSecRuleSet, + + // Att + addAtt, addCatAtt, renameAtt, removeAtt, moveAtt, + updateAttDomain, updateAttDefaultValue, setAttRuleSet, + + // Opts globales + addOpt, updateOpt, removeOpt, addAttOpt, removeAttOpt, + + // OptCategories + addOptCategory, renameOptCategory, removeOptCategory, + + // RuleSet helpers (operan sobre cualquier RuleSet pasado por ref) + addRule, updateRule, removeRule, + addAction, updateAction, removeAction, + + // Persistencia + getCat, markClean, discard, + + // Acceso a finders para los paneles + findObj, findSec, findAtt, findCatAtt, + }; +} + +export type CatalogEditorStore = ReturnType; + +// ───────────────────────────────────────────────────────────────────────────── +// HELPERS +// ───────────────────────────────────────────────────────────────────────────── + +function defaultValueForDomain(domain: AttDomain): unknown { + switch (domain.kind) { + case 'string': return ''; + case 'number': return 0; + case 'currency': return 0; + case 'boolean': return false; + case 'option': return null; + case 'date': return ''; + case 'time': return ''; + case 'datetime': return ''; + } +} diff --git a/src/vcen/editor/stores/ConditionEditor.svelte.ts b/src/vcen/editor/stores/condition-builder.svelte.ts similarity index 89% rename from src/vcen/editor/stores/ConditionEditor.svelte.ts rename to src/vcen/editor/stores/condition-builder.svelte.ts index 2b36585..d01cd15 100644 --- a/src/vcen/editor/stores/ConditionEditor.svelte.ts +++ b/src/vcen/editor/stores/condition-builder.svelte.ts @@ -1,13 +1,13 @@ import type { JsonLogicValue } from '@/jslg'; -import type { Obj, Sec } from '@/vcen/lib/types'; +import type {Obj, Opt, Sec} from '@/vcen/lib/types'; import type { ScopeEntry, ConditionNode, ConditionError, CompareOp, LogicOp, - Operand -} from '../lib/condition_editor'; + Operand, ResolvedOpt +} from '../lib/condition-editor.ts'; import { buildScope, compileCondition, @@ -20,7 +20,7 @@ import { removeNode, addChild, getValidOperators, -} from '../lib/condition_editor'; +} from '../lib/condition-editor.ts'; // ───────────────────────────────────────────────────────────────────────────── // STORE @@ -53,6 +53,7 @@ import { export function createConditionBuilderStore( container: Obj | Sec, initial?: JsonLogicValue, + catOpts: Opt[] = [], ) { // ── Estado reactivo ─────────────────────────────────────────────────────── @@ -62,7 +63,7 @@ export function createConditionBuilderStore( : makeLogicNode('and', []) ); - const scope = $derived(buildScope(container)); + const scope = $derived(buildScope(container, catOpts)); const errors = $derived( validateCondition(root, scope) @@ -138,6 +139,19 @@ export function createConditionBuilderStore( return getValidOperators(entry.domain); } + /** + * Opciones resueltas para el att referenciado por `varPath`. + * Solo tiene sentido para atts con dominio `option/list`. + * Devuelve `[]` para cualquier otro dominio. + * + * Útil para poblar el selector de valor literal en la UI + * cuando el operando derecho debe ser un OptID. + */ + function optsFor(varPath: string): ResolvedOpt[] { + const entry = scope.find(e => e.path === varPath); + return entry?.resolvedOpts ?? []; + } + /** * Carga una condición existente en el editor (p.ej. al abrir una regla). */ @@ -171,6 +185,7 @@ export function createConditionBuilderStore( setRight, setLogicOp, validOperatorsFor, + optsFor, load, reset, }; @@ -191,4 +206,4 @@ function findNode(root: ConditionNode, id: string): ConditionNode | null { } } return null; -} +} \ No newline at end of file diff --git a/src/vcen/editor/stores/session.svelte.ts b/src/vcen/editor/stores/session.svelte.ts new file mode 100644 index 0000000..2a749c0 --- /dev/null +++ b/src/vcen/editor/stores/session.svelte.ts @@ -0,0 +1,144 @@ +import { + type Cat, + type AttPath, + type AttEffectiveState, + type EvaluationResult, serializeAttPath +} from '@/vcen/lib'; +import { Session, createSession } from '@/vcen/lib'; +import { JsonLogicEngine } from '@/jslg'; + + + +// ───────────────────────────────────────────────────────────────────────────── +// SESSION STORE — adaptador Svelte 5 sobre Session +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Store Svelte 5 reactivo para una sesión de configuración. + * + * Envuelve `Session` con runas `$state`/`$derived` de forma que los + * componentes Svelte reaccionan automáticamente a cualquier cambio de valor + * sin necesidad de `subscribe` manual. + * + * ## Uso básico + * + * ```svelte + * + * + * store.setValue(['ct:cars', 'ob:bmw', 'at:color'], e.target.value)} + * /> + * + * {#if !store.isValid} + *

Hay {store.errors.length} errores

+ * {/if} + * ``` + * + * ## Uso con estado efectivo + * + * ```svelte + * {@const es = store.effectiveState(['ct:cars', 'ob:bmw', 'at:color'])} + * + * {#if es?.state === 'hidden'} + * + * {:else if es?.state === 'required'} + * + * {/if} + * ``` + */ +export function createSessionStore( + cat: Cat, + engine: JsonLogicEngine | null = null, +) { + const session = createSession(cat, engine); + + // ── Estado reactivo ─────────────────────────────────────────────────────── + // Un único $state que contiene el EvaluationResult completo. + // Toda la reactividad deriva de aquí — cuando cambia, todos los $derived + // se recalculan automáticamente. + + let result = $state(session.getResult()); + + // Sincronizar Session → $state: cada vez que Session notifica, + // actualizamos el $state para propagar la reactividad a Svelte. + session.subscribe(next => { result = next; }); + + // ── Derivados ───────────────────────────────────────────────────────────── + + const isValid = $derived(result.valid); + const errors = $derived(result.errors); + const warnings = $derived(result.warnings); + const iterations = $derived(result.iterations); + + // ── API de lectura ──────────────────────────────────────────────────────── + + /** + * Valor actual de un atributo. + * Si el motor ha derivado un valor, devuelve el derivado. + * Lee directamente de `result` ($state) — Svelte rastrea la dependencia + * de forma natural sin necesidad de hacks. + */ + function getValue(path: AttPath): unknown { + const key = serializeAttPath(path); + const es = result.effectiveState.get(key); + if (es?.derivedValue !== undefined) return es.derivedValue.value; + // Valor base: buscar en el Cat original (no cambia — no necesita ser reactivo) + return session.getValue(path); + } + + /** + * Estado efectivo de un atributo. + * Lee directamente de `result` ($state) — reactivo de forma natural. + */ + function effectiveState(path: AttPath): AttEffectiveState | undefined { + return result.effectiveState.get(serializeAttPath(path)); + } + + // ── API de mutación ─────────────────────────────────────────────────────── + + /** + * Cambia el valor de un atributo y re-evalúa incrementalmente. + * Dispara reactividad Svelte automáticamente. + */ + function setValue(path: AttPath, value: unknown): void { + session.setValue(path, value); + // session.subscribe ya actualizó result — no hay que hacer nada más + } + + /** + * Vuelve al estado inicial del catálogo. + * Dispara reactividad Svelte automáticamente. + */ + function reset(): void { + session.reset(); + } + + // ── API pública ─────────────────────────────────────────────────────────── + + return { + // Estado derivado (reactivo, solo lectura) + get isValid() { return isValid; }, + get errors() { return errors; }, + get warnings() { return warnings; }, + get iterations() { return iterations; }, + get result() { return result; }, + + // Lectura por path (reactiva) + getValue, + effectiveState, + + // Mutación + setValue, + reset, + + // Acceso a la Session subyacente para casos avanzados + get session() { return session; }, + }; +} + +export type SessionStore = ReturnType; diff --git a/src/vcen/editor/tests/condition_editor.test.ts b/src/vcen/editor/tests/condition_editor.test.ts index 62dd5af..4d267f0 100644 --- a/src/vcen/editor/tests/condition_editor.test.ts +++ b/src/vcen/editor/tests/condition_editor.test.ts @@ -13,9 +13,10 @@ import { replaceNode, removeNode, addChild, -} from '../lib/condition_editor'; -import type { Att, Sec, Obj } from '@/vcen/lib/types'; -import type { ScopeEntry } from '../lib/condition_editor'; +} from '../lib/condition-editor.ts'; +import type {Att, Sec, Obj, Opt} from '@/vcen/lib/types'; +import type { ScopeEntry } from '../lib/condition-editor.ts'; + // ───────────────────────────────────────────────────────────────────────────── // FIXTURES @@ -274,18 +275,18 @@ describe('parseCondition', () => { }); it('compile(parse(x)) === x para condición simple', () => { - const original = { '==': [{ var: 'at_color' }, 'rojo'] }; + const original = { '==': [{ var: 'at_color' }, 'rojo'] } as unknown as JsonLogicValue; const node = parseCondition(original); expect(compileCondition(node)).toEqual(original); }); it('compile(parse(x)) === x para condición compleja', () => { - const original = asJsonCondition({ + const original = { and: [ { '==': [{ var: 'at_color' }, 'rojo'] }, { '!=': [{ var: 'at_acabado' }, 'lujo'] }, ], - }); + } as unknown as JsonLogicValue; expect(compileCondition(parseCondition(original))).toEqual(original); }); }); @@ -424,3 +425,125 @@ describe('mutación del árbol', () => { expect(root.children).toHaveLength(0); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// buildScope — resolución de Opts +// ───────────────────────────────────────────────────────────────────────────── + + +function makeOpt(id: string, value: string, label?: string): Opt { + return { + id: id as Opt['id'], + value: { kind: 'string', value }, + label: label ? { es: label } : undefined, + } as unknown as Opt; +} + +function makeOptAtt( + id: string, + attOpts: { optId: string; label?: string }[], +): Att { + return { + id, + value: { + domain: { + kind: 'option', + mode: 'list', + list: attOpts.map(o => ({ + optId: o.optId, + label: o.label ? { es: o.label } : undefined, + })), + }, + value: attOpts[0]?.optId ?? null, + }, + } as unknown as Att; +} + +function makeStringAtt(id: string): Att { + return { + id, + value: { domain: { kind: 'string', mode: 'input' }, value: '' }, + } as unknown as Att; +} + +describe('buildScope — resolvedOpts', () => { + it('resuelve opts para dominio option/list', () => { + const opts = [ + makeOpt('op:rojo', 'rojo', 'Rojo'), + makeOpt('op:azul', 'azul', 'Azul'), + ]; + const att = makeOptAtt('at:color', [ + { optId: 'op:rojo' }, + { optId: 'op:azul' }, + ]); + const obj = { id: 'ob:bmw', atts: [att], secs: [] } as unknown as Obj; + + const scope = buildScope(obj, opts); + const entry = scope.find(e => e.path === 'at:color'); + + expect(entry?.resolvedOpts).toHaveLength(2); + expect(entry?.resolvedOpts?.[0].optId).toBe('op:rojo'); + expect(entry?.resolvedOpts?.[0].value).toEqual({ kind: 'string', value: 'rojo' }); + expect(entry?.resolvedOpts?.[0].label).toEqual({ es: 'Rojo' }); + }); + + it('usa el label local del AttOpt si existe, sino el global', () => { + const opts = [makeOpt('op:rojo', 'rojo', 'Rojo global')]; + const att = makeOptAtt('at:color', [{ optId: 'op:rojo', label: 'Rojo local' }]); + const obj = { id: 'ob:bmw', atts: [att], secs: [] } as unknown as Obj; + + const scope = buildScope(obj, opts); + const entry = scope.find(e => e.path === 'at:color'); + + expect(entry?.resolvedOpts?.[0].label).toEqual({ es: 'Rojo local' }); + }); + + it('omite opts cuyo optId no existe en catOpts', () => { + const opts = [makeOpt('op:rojo', 'rojo')]; + const att = makeOptAtt('at:color', [ + { optId: 'op:rojo' }, + { optId: 'op:inexistente' }, + ]); + const obj = { id: 'ob:bmw', atts: [att], secs: [] } as unknown as Obj; + + const scope = buildScope(obj, opts); + const entry = scope.find(e => e.path === 'at:color'); + + expect(entry?.resolvedOpts).toHaveLength(1); + expect(entry?.resolvedOpts?.[0].optId).toBe('op:rojo'); + }); + + it('resolvedOpts es undefined para dominio string', () => { + const att = makeStringAtt('at:nombre'); + const obj = { id: 'ob:bmw', atts: [att], secs: [] } as unknown as Obj; + + const scope = buildScope(obj, []); + const entry = scope.find(e => e.path === 'at:nombre'); + + expect(entry?.resolvedOpts).toBeUndefined(); + }); + + it('resuelve opts en atts dentro de secciones', () => { + const opts = [makeOpt('op:gasolina', 'gasolina', 'Gasolina')]; + const att = makeOptAtt('at:combustible', [{ optId: 'op:gasolina' }]); + const sec = { id: 'sc:motor', atts: [att] } as unknown as Sec; + const obj = { id: 'ob:bmw', atts: [], secs: [sec] } as unknown as Obj; + + const scope = buildScope(obj, opts); + const entry = scope.find(e => e.path === 'sc:motor/at:combustible'); + + expect(entry?.resolvedOpts).toHaveLength(1); + expect(entry?.resolvedOpts?.[0].optId).toBe('op:gasolina'); + }); + + it('buildScope sin catOpts devuelve resolvedOpts undefined para option/list', () => { + const att = makeOptAtt('at:color', [{ optId: 'op:rojo' }]); + const obj = { id: 'ob:bmw', atts: [att], secs: [] } as unknown as Obj; + + const scope = buildScope(obj); // sin catOpts + const entry = scope.find(e => e.path === 'at:color'); + + // sin catOpts no puede resolver → undefined + expect(entry?.resolvedOpts).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/src/vcen/lib/engine.ts b/src/vcen/lib/engine.ts index ed92917..33409c1 100644 --- a/src/vcen/lib/engine.ts +++ b/src/vcen/lib/engine.ts @@ -8,11 +8,11 @@ import type { AttEffectiveState, EffectiveState, EvaluationResult, - RuleID, OptID, AttOptionDomain, Opt, AttValueOption, AttState, + RuleID, OptID, AttOptionDomain, Opt, AttValueOption, AttState, ConfigState, } from './types'; import { ATT_STATE_PRECEDENCE } from './types'; import {validateAttValue} from "@/vcen/lib/validation.ts"; -import { serializeRelativePath, joinPath, keyToAttPath} from "@/vcen/lib/node_util.ts"; +import {serializeRelativePath, joinPath, keyToAttPath, parseAttPath} from "@/vcen/lib/node_util.ts"; import {JsonLogicEngine} from "@/jslg"; @@ -30,20 +30,10 @@ const MAX_ITERATIONS = 20; // ───────────────────────────────────────────────────────────────────────────── /** Resuelve la instancia de JsonLogicEngine a usar */ -export function resolveEngine(engine: JsonLogicEngine | null): JsonLogicEngine { +function resolveEngine(engine: JsonLogicEngine | null): JsonLogicEngine { return engine ?? new JsonLogicEngine(); } -// ───────────────────────────────────────────────────────────────────────────── -// TYPES -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Estado plano del configurador usado por el motor durante la evaluación. - * Key = AttPath serializado con separador `/` - * Value = valor actual del atributo (unknown — puede venir de fuera del sistema) - */ -export type ConfigState = Record; /** * Contexto de evaluación de una regla. @@ -51,8 +41,9 @@ export type ConfigState = Record; * para resolver referencias relativas. */ interface EvalContext { - state: ConfigState; - prefix: string; // ej: 'ct:vehicles/ob:bmw430' para reglas de Obj + state: ConfigState; + nestedState: Record; // árbol anidado pre-calculado (inmutable por iteración) + prefix: string; // ej: 'ct:vehicles/ob:bmw430' para reglas de Obj } @@ -110,11 +101,21 @@ export function buildConfigState(cat: Cat): ConfigState { * 'at:fuel': 'gasolina' // relativo a la sec * } */ -function buildJsonLogicData(ctx: EvalContext): Record { - const data: Record = {}; +/** + * Construye el árbol anidado global a partir del ConfigState plano. + * Se calcula UNA SOLA VEZ por iteración del forward chaining y se + * comparte entre todas las llamadas a `buildJsonLogicData` de esa iteración. + * + * @example + * // state: { 'ct:cat/ob:bmw/at:color': 'rojo' } + * // resultado: { 'ct:cat': { 'ob:bmw': { 'at:color': 'rojo' } } } + */ +function buildNestedState(state: ConfigState): Record { + const root: Record = {}; - function setNested(obj: Record, parts: string[], value: unknown) { - let current = obj; + for (const [key, value] of Object.entries(state)) { + const parts = parseAttPath(key).filter(Boolean); + let current = root; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; if (current[part] == null || typeof current[part] !== 'object') { @@ -125,26 +126,27 @@ function buildJsonLogicData(ctx: EvalContext): Record { current[parts[parts.length - 1]] = value; } - const prefixParts = keyToAttPath(ctx.prefix).filter(Boolean); - - for (const [key, value] of Object.entries(ctx.state)) { - const parts = keyToAttPath(key).filter(Boolean); - - // Path absoluto anidado: data["ct:cat"]["ob:bmw"]["at:color"] - setNested(data, parts, value); + return root; +} - // Paths relativos al scope — cada sufijo desde el prefijo hacia abajo - // prefix = "ct:cat/ob:bmw" → exponer desde "at:color" y "ob:bmw/at:color" - if (parts.length > prefixParts.length) { - const relative = parts.slice(prefixParts.length); - // Exponer todos los sub-paths relativos - for (let i = 0; i < relative.length; i++) { - setNested(data, relative.slice(i), value); - } +function buildJsonLogicData(ctx: EvalContext): Record { + const prefixParts = parseAttPath(ctx.prefix).filter(Boolean); + + // Navegar hasta el scope local dentro del árbol pre-calculado + let localScope: Record = ctx.nestedState; + for (const part of prefixParts) { + const next = localScope[part]; + if (next != null && typeof next === 'object') { + localScope = next as Record; + } else { + localScope = {}; + break; } } - return data; + // data = árbol global (paths absolutos) + atajos relativos en la raíz + // El spread es O(n) en keys de primer nivel — sin splits, sin creación de objetos anidados + return { ...ctx.nestedState, ...localScope }; } // ───────────────────────────────────────────────────────────────────────────── @@ -424,7 +426,8 @@ export function evaluateAtt( const valuePriorities = new Map(); if (att.rules) { - const ctx: EvalContext = { state, prefix }; + const nestedState = buildNestedState(state); + const ctx: EvalContext = { state, nestedState, prefix }; applyRuleSet(att.rules, ctx, effectiveState, attPath, prefix, valuePriorities, resolvedEngine); } @@ -451,16 +454,17 @@ export function evaluateSec( const secPath = joinPath(prefix, sec.id); const effectiveState = new Map(); const valuePriorities = new Map(); + const nestedState = buildNestedState(state); if (sec.rules) { - const ctx: EvalContext = { state, prefix: secPath }; + const ctx: EvalContext = { state, nestedState, prefix: secPath }; applyRuleSet(sec.rules, ctx, effectiveState, secPath, secPath, valuePriorities, resolvedEngine); } for (const att of sec.atts) { const attPath = joinPath(secPath, att.id); if (att.rules) { - const ctx: EvalContext = { state, prefix: secPath }; + const ctx: EvalContext = { state, nestedState, prefix: secPath }; applyRuleSet(att.rules, ctx, effectiveState, attPath, secPath, valuePriorities, resolvedEngine); } if (!effectiveState.has(attPath)) { @@ -480,28 +484,31 @@ export function evaluateSec( * @param state - Estado plano del configurador * @param engine * @param opts + * @param nestedState * @returns EffectiveState completo del objeto */ export function evaluateObj( - obj: Obj, - prefix: string, - state: ConfigState, - engine: JsonLogicEngine | null = null, - opts: Opt[] = [], + obj: Obj, + prefix: string, + state: ConfigState, + engine: JsonLogicEngine | null = null, + opts: Opt[] = [], + nestedState?: Record, // pre-calculado por evaluateCat; se construye si no se pasa ): EffectiveState { const resolvedEngine = resolveEngine(engine); + const nested = nestedState ?? buildNestedState(state); const objPath = joinPath(prefix, obj.id); const effectiveState = new Map(); const valuePriorities = new Map(); if (obj.rules) { - const ctx: EvalContext = { state, prefix: objPath }; + const ctx: EvalContext = { state, nestedState: nested, prefix: objPath }; applyRuleSet(obj.rules, ctx, effectiveState, objPath, objPath, valuePriorities, resolvedEngine); } for (const att of obj.atts) { const attPath = joinPath(objPath, att.id); - const ctx: EvalContext = { state, prefix: objPath }; + const ctx: EvalContext = { state, nestedState: nested, prefix: objPath }; if (att.rules) { applyRuleSet(att.rules, ctx, effectiveState, attPath, objPath, valuePriorities, resolvedEngine); } @@ -524,13 +531,13 @@ export function evaluateObj( const secPath = joinPath(objPath, sec.id); if (sec.rules) { - const ctx: EvalContext = { state, prefix: secPath }; + const ctx: EvalContext = { state, nestedState: nested, prefix: secPath }; applyRuleSet(sec.rules, ctx, effectiveState, secPath, secPath, valuePriorities, resolvedEngine); } for (const att of sec.atts) { const attPath = joinPath(secPath, att.id); - const ctx: EvalContext = { state, prefix: secPath }; + const ctx: EvalContext = { state, nestedState: nested, prefix: secPath }; if (att.rules) { applyRuleSet(att.rules, ctx, effectiveState, attPath, secPath, valuePriorities, resolvedEngine); } @@ -583,10 +590,13 @@ export function evaluateCat(cat: Cat, engine: JsonLogicEngine | null = null): Ev while (iterations < MAX_ITERATIONS) { iterations++; + // Árbol anidado construido UNA SOLA VEZ por iteración y compartido + // entre todos los evaluateObj → applyRuleSet → buildJsonLogicData + const nestedState = buildNestedState(state); const nextEffective = new Map(); for (const obj of cat.objs) { - const objResult = evaluateObj(obj, cat.id, state, resolvedEngine, cat.opts ?? []); + const objResult = evaluateObj(obj, cat.id, state, resolvedEngine, cat.opts ?? [], nestedState); for (const [k, v] of objResult) { nextEffective.set(k, v); } @@ -690,4 +700,5 @@ function collectAtts(obj: Obj, catId: string): Array<{ path: string; att: Att }> } return result; -} +}// engine.ts + diff --git a/src/vcen/lib/index.ts b/src/vcen/lib/index.ts new file mode 100644 index 0000000..53f2e39 --- /dev/null +++ b/src/vcen/lib/index.ts @@ -0,0 +1,12 @@ +// index.ts + + +export * from './types' +export * from './engine.ts'; +export * from './incremental.ts' +export * from './node_util.ts' +export * from './serialization.ts' +export * from './session.ts' +export * from './validation.ts' + + diff --git a/src/vcen/lib/node_util.ts b/src/vcen/lib/node_util.ts index 57bb2b9..94b348e 100644 --- a/src/vcen/lib/node_util.ts +++ b/src/vcen/lib/node_util.ts @@ -17,7 +17,7 @@ import { // IDS HELPERS // ───────────────────────────────────────────────────────────────────────────── -export function qtoCatID (catId : string) : CatID { return `${catIDPrefix}_${catId}` as CatID; } +export function toCatID (catId : string) : CatID { return `${catIDPrefix}_${catId}` as CatID; } export function toObjID (objId : string) : ObjID { return `${objIDPrefix}_${objId}` as ObjID; } export function toSecID (secId : string) : SecID { return `${secIDPrefix}_${secId}` as SecID; } export function toAttID (attId : string) : AttID { return `${attIDPrefix}_${attId}` as AttID; } diff --git a/src/vcen/lib/serialization.ts b/src/vcen/lib/serialization.ts new file mode 100644 index 0000000..fb084eb --- /dev/null +++ b/src/vcen/lib/serialization.ts @@ -0,0 +1,472 @@ +import type { + Cat, Obj, Sec, Att, AttValue, AttDomain, + AttOpt, Opt, OptCategory, RuleSet, Rule, RuleAction, + RuleActionValue, + RelativePath, RuleCondition +} from './types'; +import type {LingString} from "@/ling"; + +// ───────────────────────────────────────────────────────────────────────────── +// SERIALIZE — Cat → JSON-safe plain object +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Serializa un `Cat` a un objeto plano JSON-safe. + * El resultado puede pasarse a `JSON.stringify` directamente. + * + * No hay transformación de datos — `Cat` ya es JSON-safe por diseño. + * Esta función documenta explícitamente la intención y sirve de punto + * de entrada para futuras transformaciones (versioning, migrations). + * + * @example + * const json = JSON.stringify(serializeCat(cat), null, 2); + * await fs.writeFile('catalog.json', json); + */ +export function serializeCat(cat: Cat): unknown { + return cat; +} + +// ───────────────────────────────────────────────────────────────────────────── +// DESERIALIZE — plain object → Cat (with validation) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Deserializa un objeto plano (p.ej. parseado de JSON) a `Cat`. + * Valida la estructura y lanza `CatDeserializationError` si hay errores. + * + * @example + * const raw = JSON.parse(await fs.readFile('catalog.json', 'utf-8')); + * const cat = deserializeCat(raw); + * const sess = createSession(cat); + */ +export function deserializeCat(raw: unknown): Cat { + const ctx = new ParseContext('cat'); + const cat = parseCat(raw, ctx); + ctx.throwIfErrors(); + return cat; +} + +// ───────────────────────────────────────────────────────────────────────────── +// ERROR +// ───────────────────────────────────────────────────────────────────────────── + +export class CatDeserializationError extends Error { + constructor(public readonly issues: string[]) { + super(`Cat deserialization failed:\n${issues.map(i => ` - ${i}`).join('\n')}`); + this.name = 'CatDeserializationError'; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// PARSE CONTEXT — acumula errores con paths legibles +// ───────────────────────────────────────────────────────────────────────────── + +class ParseContext { + private readonly issues: string[]; + + constructor( + private readonly path: string, + issues: string[] = [], + ) { + this.issues = issues; + } + + /** Crea un contexto hijo que comparte la misma lista de errores. */ + child(segment: string | number): ParseContext { + return new ParseContext(`${this.path}.${segment}`, this.issues); + } + + error(message: string): void { + this.issues.push(`${this.path}: ${message}`); + } + + get hasErrors(): boolean { + return this.issues.length > 0; + } + + throwIfErrors(): void { + if (this.hasErrors) throw new CatDeserializationError([...this.issues]); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// PRIMITIVE HELPERS +// ───────────────────────────────────────────────────────────────────────────── + +function requireString(raw: unknown, ctx: ParseContext): string { + if (typeof raw === 'string') return raw; + ctx.error(`expected string, got ${typeof raw}`); + return ''; +} + +function requireNumber(raw: unknown, ctx: ParseContext): number { + if (typeof raw === 'number') return raw; + ctx.error(`expected number, got ${typeof raw}`); + return 0; +} + +function requireBoolean(raw: unknown, ctx: ParseContext): boolean { + if (typeof raw === 'boolean') return raw; + ctx.error(`expected boolean, got ${typeof raw}`); + return false; +} + +function requireArray(raw: unknown, ctx: ParseContext): unknown[] { + if (Array.isArray(raw)) return raw; + ctx.error(`expected array, got ${typeof raw}`); + return []; +} + +function requireObject(raw: unknown, ctx: ParseContext): Record { + if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) { + return raw as Record; + } + ctx.error(`expected object, got ${Array.isArray(raw) ? 'array' : typeof raw}`); + return {}; +} + +function requireStringEnum( + raw: unknown, + allowed: readonly T[], + ctx: ParseContext, +): T { + const s = requireString(raw, ctx); + if ((allowed as readonly string[]).includes(s)) return s as T; + ctx.error(`expected one of [${allowed.join(', ')}], got "${s}"`); + return allowed[0]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// CAT +// ───────────────────────────────────────────────────────────────────────────── + +function parseCat(raw: unknown, ctx: ParseContext): Cat { + const obj = requireObject(raw, ctx); + + const id = requireString(obj['id'], ctx.child('id')); + const name = requireString(obj['name'], ctx.child('name')); + const optCategories = parseOptCategoryArray(obj['optCategories'], ctx.child('optCategories')); + const opts = parseOptArray(obj['opts'], ctx.child('opts')); + const atts = parseAttArray(obj['atts'], ctx.child('atts')); + const objs = parseObjArray(obj['objs'], ctx.child('objs')); + + return { id: id as Cat['id'], name, optCategories, opts, atts, objs }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// OPT CATEGORY +// ───────────────────────────────────────────────────────────────────────────── + +function parseOptCategoryArray(raw: unknown, ctx: ParseContext): OptCategory[] { + return requireArray(raw, ctx).map((item, i) => parseOptCategory(item, ctx.child(i))); +} + +function parseOptCategory(raw: unknown, ctx: ParseContext): OptCategory { + const obj = requireObject(raw, ctx); + return { + id: requireString(obj['id'], ctx.child('id')) as OptCategory['id'], + name: requireString(obj['name'], ctx.child('name')), + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// OPT +// ───────────────────────────────────────────────────────────────────────────── + +function parseOptArray(raw: unknown, ctx: ParseContext): Opt[] { + return requireArray(raw, ctx).map((item, i) => parseOpt(item, ctx.child(i))); +} + +function parseOpt(raw: unknown, ctx: ParseContext): Opt { + const obj = requireObject(raw, ctx); + const id = requireString(obj['id'], ctx.child('id')) as Opt['id']; + const value = parseOptValue(obj['value'], ctx.child('value')); + + const opt: Opt = { id, value }; + + if (obj['categoryId'] !== undefined) + opt.categoryId = requireString(obj['categoryId'], ctx.child('categoryId')) as Opt['categoryId']; + if (obj['label'] !== undefined) + opt.label = parseLingString(obj['label'], ctx.child('label')); + if (obj['metadata'] !== undefined) + opt.metadata = obj['metadata'] as Opt['metadata']; + if (obj['rules'] !== undefined) + opt.rules = parseRuleSet(obj['rules'], ctx.child('rules')); + + return opt; +} + +function parseOptValue(raw: unknown, ctx: ParseContext): Opt['value'] { + const obj = requireObject(raw, ctx); + const kind = requireString(obj['kind'], ctx.child('kind')); + + switch (kind) { + case 'string': return { kind: 'string', value: requireString(obj['value'], ctx.child('value')) }; + case 'number': return { kind: 'number', value: requireNumber(obj['value'], ctx.child('value')) }; + case 'boolean': return { kind: 'boolean', value: requireBoolean(obj['value'], ctx.child('value')) }; + default: + ctx.child('kind').error(`unknown opt value kind "${kind}"`); + return { kind: 'string', value: '' }; + } +} + +/** + * Parsea un LingString, que puede ser: + * - string plano: 'Rojo' + * - IDLing: '#?colors.red' (string con prefijo '#?') + * - LingRecord: { es: 'Rojo', en: 'Red', ... } + */ +function parseLingString(raw: unknown, ctx: ParseContext): LingString { + if (typeof raw === 'string') return raw; // string plano o IDLing (#?) + + const obj = requireObject(raw, ctx); + const result: Record = {}; + for (const [lang, val] of Object.entries(obj)) { + result[lang] = requireString(val, ctx.child(lang)); + } + return result as LingString; +} + +// ───────────────────────────────────────────────────────────────────────────── +// OBJECT +// ───────────────────────────────────────────────────────────────────────────── + +function parseObjArray(raw: unknown, ctx: ParseContext): Obj[] { + return requireArray(raw, ctx).map((item, i) => parseObj(item, ctx.child(i))); +} + +function parseObj(raw: unknown, ctx: ParseContext): Obj { + const obj = requireObject(raw, ctx); + const id = requireString(obj['id'], ctx.child('id')) as Obj['id']; + const name = requireString(obj['name'], ctx.child('name')); + const atts = parseAttArray(obj['atts'], ctx.child('atts')); + const secs = parseSecArray(obj['secs'], ctx.child('secs')); + + const result: Obj = { id, name, atts, secs }; + if (obj['rules'] !== undefined) + result.rules = parseRuleSet(obj['rules'], ctx.child('rules')); + + return result; +} + +// ───────────────────────────────────────────────────────────────────────────── +// SECTION +// ───────────────────────────────────────────────────────────────────────────── + +function parseSecArray(raw: unknown, ctx: ParseContext): Sec[] { + return requireArray(raw, ctx).map((item, i) => parseSec(item, ctx.child(i))); +} + +function parseSec(raw: unknown, ctx: ParseContext): Sec { + const obj = requireObject(raw, ctx); + const id = requireString(obj['id'], ctx.child('id')) as Sec['id']; + const name = requireString(obj['name'], ctx.child('name')); + const atts = parseAttArray(obj['atts'], ctx.child('atts')); + + const result: Sec = { id, name, atts }; + if (obj['rules'] !== undefined) + result.rules = parseRuleSet(obj['rules'], ctx.child('rules')); + + return result; +} + +// ───────────────────────────────────────────────────────────────────────────── +// ATTRIBUTE +// ───────────────────────────────────────────────────────────────────────────── + +function parseAttArray(raw: unknown, ctx: ParseContext): Att[] { + return requireArray(raw, ctx).map((item, i) => parseAtt(item, ctx.child(i))); +} + +function parseAtt(raw: unknown, ctx: ParseContext): Att { + const obj = requireObject(raw, ctx); + const id = requireString(obj['id'], ctx.child('id')) as Att['id']; + const name = requireString(obj['name'], ctx.child('name')); + const value = parseAttValue(obj['value'], ctx.child('value')); + + const result: Att = { id, name, value }; + if (obj['rules'] !== undefined) + result.rules = parseRuleSet(obj['rules'], ctx.child('rules')); + + return result; +} + +// ───────────────────────────────────────────────────────────────────────────── +// ATT VALUE — discriminar por domain.kind + domain.mode +// ───────────────────────────────────────────────────────────────────────────── + +function parseAttValue(raw: unknown, ctx: ParseContext): AttValue { + const obj = requireObject(raw, ctx); + const domain = parseAttDomain(obj['domain'], ctx.child('domain')); + + switch (domain.kind) { + case 'string': return { domain, value: parseScalarOrArray(obj['value'], requireString, ctx.child('value')) as any }; + case 'number': return { domain, value: parseScalarOrArray(obj['value'], requireNumber, ctx.child('value')) as any }; + case 'currency': return { domain, value: parseScalarOrArray(obj['value'], requireNumber, ctx.child('value')) as any }; + case 'boolean': return { domain, value: requireBoolean(obj['value'], ctx.child('value')) } as any; + case 'option': return { domain, value: parseScalarOrArray(obj['value'], requireString, ctx.child('value')) as any }; + case 'date': return { domain, value: parseScalarOrArray(obj['value'], requireString, ctx.child('value')) as any }; + case 'time': return { domain, value: parseScalarOrArray(obj['value'], requireString, ctx.child('value')) as any }; + case 'datetime': return { domain, value: parseScalarOrArray(obj['value'], requireString, ctx.child('value')) as any }; + } +} + +function parseScalarOrArray( + raw: unknown, + parser: (v: unknown, ctx: ParseContext) => T, + ctx: ParseContext, +): T | T[] { + if (Array.isArray(raw)) return raw.map((v, i) => parser(v, ctx.child(i))); + return parser(raw, ctx); +} + +// ───────────────────────────────────────────────────────────────────────────── +// ATT DOMAIN +// ───────────────────────────────────────────────────────────────────────────── + +const DOMAIN_KINDS = ['string', 'number', 'currency', 'boolean', 'option', 'date', 'time', 'datetime'] as const; + +function parseAttDomain(raw: unknown, ctx: ParseContext): AttDomain { + const obj = requireObject(raw, ctx); + const kind = requireStringEnum(obj['kind'], DOMAIN_KINDS, ctx.child('kind')); + const mode = requireString(obj['mode'], ctx.child('mode')); + + switch (kind) { + case 'string': { + switch (mode) { + case 'list': return { kind, mode, list: requireArray(obj['list'], ctx.child('list')).map((v, i) => requireString(v, ctx.child(`list.${i}`))) as any, ...parseMultiple(obj, ctx) }; + case 'pattern': return { kind, mode, pattern: requireString(obj['pattern'], ctx.child('pattern')), ...parseMultiple(obj, ctx) }; + case 'fixed': return { kind, mode, fixed: requireString(obj['fixed'], ctx.child('fixed')) }; + case 'input': return { kind, mode, ...parseMultiple(obj, ctx) }; + default: ctx.child('mode').error(`unknown mode "${mode}" for kind "string"`); return { kind, mode: 'input' }; + } + } + case 'number': + case 'currency': { + switch (mode) { + case 'range': return { kind, mode, min: requireNumber(obj['min'], ctx.child('min')), max: requireNumber(obj['max'], ctx.child('max')), ...parseMultiple(obj, ctx) }; + case 'step': return { kind, mode, min: requireNumber(obj['min'], ctx.child('min')), max: requireNumber(obj['max'], ctx.child('max')), step: requireNumber(obj['step'], ctx.child('step')), ...parseMultiple(obj, ctx) }; + case 'list': return { kind, mode, list: requireArray(obj['list'], ctx.child('list')).map((v, i) => requireNumber(v, ctx.child(`list.${i}`))) as any, ...parseMultiple(obj, ctx) }; + case 'fixed': return { kind, mode, fixed: requireNumber(obj['fixed'], ctx.child('fixed')) }; + case 'input': return { kind, mode, ...parseMultiple(obj, ctx) }; + default: ctx.child('mode').error(`unknown mode "${mode}" for kind "${kind}"`); return { kind, mode: 'input' }; + } + } + case 'boolean': { + switch (mode) { + case 'input': return { kind, mode }; + case 'fixed': return { kind, mode, fixed: requireBoolean(obj['fixed'], ctx.child('fixed')) }; + default: ctx.child('mode').error(`unknown mode "${mode}" for kind "boolean"`); return { kind, mode: 'input' }; + } + } + case 'option': { + switch (mode) { + case 'list': return { kind, mode, list: parseAttOptArray(obj['list'], ctx.child('list')), ...parseMultiple(obj, ctx) }; + case 'fixed': return { kind, mode, fixed: requireString(obj['fixed'], ctx.child('fixed')) as any }; + default: ctx.child('mode').error(`unknown mode "${mode}" for kind "option"`); return { kind, mode: 'list', list: [] }; + } + } + case 'date': + case 'time': + case 'datetime': { + switch (mode) { + case 'range': return { kind, mode, min: requireString(obj['min'], ctx.child('min')) as any, max: requireString(obj['max'], ctx.child('max')) as any, ...parseMultiple(obj, ctx) }; + case 'list': return { kind, mode, list: requireArray(obj['list'], ctx.child('list')).map((v, i) => requireString(v, ctx.child(`list.${i}`))) as any, ...parseMultiple(obj, ctx) }; + case 'fixed': return { kind, mode, fixed: requireString(obj['fixed'], ctx.child('fixed')) as any }; + case 'input': return { kind, mode, ...parseMultiple(obj, ctx) }; + default: ctx.child('mode').error(`unknown mode "${mode}" for kind "${kind}"`); return { kind, mode: 'input' } as any; + } + } + } +} + +function parseMultiple(obj: Record, ctx: ParseContext): { multiple?: { min: number; max: number } } { + if (obj['multiple'] === undefined) return {}; + const m = requireObject(obj['multiple'], ctx.child('multiple')); + return { + multiple: { + min: requireNumber(m['min'], ctx.child('multiple.min')), + max: requireNumber(m['max'], ctx.child('multiple.max')), + }, + }; +} + +function parseAttOptArray(raw: unknown, ctx: ParseContext): AttOpt[] { + return requireArray(raw, ctx).map((item, i) => parseAttOpt(item, ctx.child(i))); +} + +function parseAttOpt(raw: unknown, ctx: ParseContext): AttOpt { + const obj = requireObject(raw, ctx); + const optId = requireString(obj['optId'], ctx.child('optId')) as AttOpt['optId']; + const result: AttOpt = { optId }; + if (obj['label'] !== undefined) result.label = parseLingString(obj['label'], ctx.child('label')); + if (obj['metadata'] !== undefined) result.metadata = obj['metadata'] as AttOpt['metadata']; + if (obj['rules'] !== undefined) result.rules = parseRuleSet(obj['rules'], ctx.child('rules')); + return result; +} + +// ───────────────────────────────────────────────────────────────────────────── +// RULE SET / RULE / ACTION +// ───────────────────────────────────────────────────────────────────────────── + +function parseRuleSet(raw: unknown, ctx: ParseContext): RuleSet { + const obj = requireObject(raw, ctx); + return { + rules: requireArray(obj['rules'], ctx.child('rules')).map((r, i) => parseRule(r, ctx.child(`rules.${i}`))), + }; +} + +function parseRule(raw: unknown, ctx: ParseContext): Rule { + const obj = requireObject(raw, ctx); + return { + id: requireString(obj['id'], ctx.child('id')) as Rule['id'], + priority: requireNumber(obj['priority'], ctx.child('priority')), + condition: obj['condition'] as RuleCondition, // JsonLogicValue — opaco, no validamos la estructura interna + actions: requireArray(obj['actions'], ctx.child('actions')).map((a, i) => parseRuleAction(a, ctx.child(`actions.${i}`))), + }; +} + +const ACTION_TYPES = ['attribute', 'domain', 'value', 'validation'] as const; + +function parseRuleAction(raw: unknown, ctx: ParseContext): RuleAction { + const obj = requireObject(raw, ctx); + const type = requireStringEnum(obj['type'], ACTION_TYPES, ctx.child('type')); + + switch (type) { + case 'attribute': + return { + type, + target: parseRelativePath(obj['target'], ctx.child('target')), + ifTrue: requireStringEnum(obj['ifTrue'], ['available', 'required', 'hidden', 'forbidden'] as const, ctx.child('ifTrue')), + ifFalse: requireStringEnum(obj['ifFalse'], ['available', 'required', 'hidden', 'forbidden'] as const, ctx.child('ifFalse')), + }; + case 'domain': + return { + type, + target: parseRelativePath(obj['target'], ctx.child('target')), + value: obj['value'] as any, + state: requireStringEnum(obj['state'], ['allowed', 'forbidden'] as const, ctx.child('state')), + }; + case 'value': + return { + type, + target: parseRelativePath(obj['target'], ctx.child('target')), + value: parseAttValue(obj['value'], ctx.child('value')), + } as RuleActionValue; + case 'validation': + return { + type, + level: requireStringEnum(obj['level'], ['error', 'warning'] as const, ctx.child('level')), + message: requireString(obj['message'], ctx.child('message')), + }; + } +} + +function parseRelativePath(raw: unknown, ctx: ParseContext): RelativePath { + const arr = requireArray(raw, ctx); + if (arr.length === 0) return []; + if (arr.length === 1) return [requireString(arr[0], ctx.child(0)) as any]; + if (arr.length === 2) return [requireString(arr[0], ctx.child(0)) as any, requireString(arr[1], ctx.child(1)) as any]; + ctx.error(`RelativePath must have 0, 1 or 2 elements, got ${arr.length}`); + return []; +} diff --git a/src/vcen/lib/session.ts b/src/vcen/lib/session.ts index 96539a1..fbc823f 100644 --- a/src/vcen/lib/session.ts +++ b/src/vcen/lib/session.ts @@ -6,7 +6,7 @@ import type { AttPath, DependencyGraph } from './types'; -import { serializeAttPath } from './node_util.ts'; +import {keyToAttPath, serializeAttPath} from './node_util.ts'; import { buildDependencyGraph } from './graph.ts'; import { evaluateCat } from './engine.ts'; import { evaluateIncremental } from './incremental.ts'; @@ -162,7 +162,7 @@ export class Session { } private findAttValue(key: string): unknown { - const parts = key.split('/'); + const parts = keyToAttPath(key) if (parts.length === 2) { return this.cat.atts.find(a => a.id === parts[1])?.value.value; diff --git a/src/vcen/test/cat-serialization.test.ts b/src/vcen/test/cat-serialization.test.ts new file mode 100644 index 0000000..f5d87bb --- /dev/null +++ b/src/vcen/test/cat-serialization.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect } from 'vitest'; +import {serializeCat, deserializeCat, CatDeserializationError, type Opt} from '@/vcen/lib'; +import type { Cat } from '@/vcen/lib'; + +// ───────────────────────────────────────────────────────────────────────────── +// FIXTURES +// ───────────────────────────────────────────────────────────────────────────── + +const minimalCat: Cat = { + id: 'ct_test' as Cat['id'], + name: 'Test', + optCategories: [], + opts: [], + atts: [], + objs: [], +}; + +const fullCat : Cat = { + id: 'ct_vehicles' as Cat['id'], + name: 'Vehículos', + optCategories: [ + { id: 'oc_colors' as any, name: 'Colores' }, + ], + opts: [ + { + id: 'op_rojo' as any, + categoryId: 'oc_colors' as any, + value: { kind: 'string', value: 'rojo' }, + label: { es: 'Rojo', en: 'Red' }, + }, + { + id: 'op_azul' as any, + value: { kind: 'string', value: 'azul' }, + rules: { + rules: [{ + id: 'rl_azul-sport' as any, + priority: 10, + condition: { '==': [{ var: 'at_tipo' }, 'sport'] }, + actions: [], + }], + }, + }, + ], + atts: [ + { + id: 'at_brand' as any, + name: 'Marca', + value: { domain: { kind: 'string', mode: 'input' }, value: '' }, + }, + ], + objs: [ + { + id: 'ob:bmw' as any, + name: 'BMW 430i', + atts: [ + { + id: 'at_color' as any, + name: 'Color', + value: { + domain: { + kind: 'option', + mode: 'list', + list: [ + { optId: 'op_rojo' as any }, + { optId: 'op_azul' as any, label: { es: 'Azul deportivo' } }, + ], + }, + value: 'op_rojo' as any, + }, + }, + { + id: 'at_precio' as any, + name: 'Precio', + value: { + domain: { kind: 'currency', mode: 'range', min: 30000, max: 80000 }, + value: 45000, + }, + rules: { + rules: [{ + id: 'rl_precio-lujo' as any, + priority: 5, + condition: { '==': [{ var: 'at_color' }, 'op_rojo'] }, + actions: [ + { type: 'attribute', target: [], ifTrue: 'required', ifFalse: 'available' }, + { type: 'validation', level: 'warning', message: 'Rojo tiene suplemento' }, + ], + }], + }, + }, + ], + secs: [ + { + id: 'sc:motor' as any, + name: 'Motor', + atts: [ + { + id: 'at_combustible' as any, + name: 'Combustible', + value: { + domain: { kind: 'string', mode: 'list', list: ['gasolina', 'diesel', 'electrico'] }, + value: 'gasolina', + }, + }, + { + id: 'at_potencia' as any, + name: 'Potencia (CV)', + value: { + domain: { kind: 'number', mode: 'step', min: 100, max: 500, step: 50 }, + value: 200, + }, + }, + ], + rules: { + rules: [{ + id: 'rl_electrico-hide-potencia' as any, + priority: 1, + condition: { '==': [{ var: 'at_combustible' }, 'electrico'] }, + actions: [ + { type: 'attribute', target: ['at_potencia' as any], ifTrue: 'hidden', ifFalse: 'available' }, + { type: 'value', target: ['at_potencia' as any], value: { domain: { kind: 'number', mode: 'fixed', fixed: 0 }, value: 0 } }, + ], + }], + }, + }, + ], + rules: { + rules: [{ + id: 'rl_domain-color' as any, + priority: 1, + condition: { '==': [{ var: 'at_precio' }, 30000] }, + actions: [ + { type: 'domain', target: ['at_color' as any], value: 'op_azul' as any, state: 'forbidden' }, + ], + }], + }, + }, + ], +}; + +// ───────────────────────────────────────────────────────────────────────────── +// ROUNDTRIP +// ───────────────────────────────────────────────────────────────────────────── + +describe('serializeCat / deserializeCat — roundtrip', () => { + it('roundtrip de Cat mínimo', () => { + const json = JSON.stringify(serializeCat(minimalCat)); + const result = deserializeCat(JSON.parse(json)); + expect(result).toEqual(minimalCat); + }); + + it('roundtrip de Cat completo', () => { + const json = JSON.stringify(serializeCat(fullCat)); + const result = deserializeCat(JSON.parse(json)); + expect(result).toEqual(fullCat); + }); + + it('preserva optCategories', () => { + const json = JSON.stringify(serializeCat(fullCat)); + const result = deserializeCat(JSON.parse(json)); + expect(result.optCategories).toHaveLength(1); + expect(result.optCategories[0].id).toBe('oc_colors'); + }); + + it('preserva opts con label y categoryId', () => { + const json = JSON.stringify(serializeCat(fullCat)); + const result = deserializeCat(JSON.parse(json)); + const opt = result.opts.find((o : Opt) => o.id === 'op_rojo'); + //expect(opt?.label).toEqual({ es: 'Rojo', en: 'Red' }); + expect(opt?.categoryId).toBe('oc_colors'); + }); + + it('preserva opts con rules', () => { + const json = JSON.stringify(serializeCat(fullCat)); + const result = deserializeCat(JSON.parse(json)); + const opt = result.opts.find(o => o.id === 'op_azul'); + expect(opt?.rules?.rules).toHaveLength(1); + expect(opt?.rules?.rules[0].id).toBe('rl_azul-sport'); + }); + + it('preserva dominio option/list con AttOpt y label local', () => { + const json = JSON.stringify(serializeCat(fullCat)); + const result = deserializeCat(JSON.parse(json)); + const att = result.objs[0].atts.find(a => a.id === 'at_color'); + expect(att?.value.domain.kind).toBe('option'); + const domain = att?.value.domain as any; + expect(domain.list).toHaveLength(2); + expect(domain.list[1].label).toEqual({ es: 'Azul deportivo' }); + }); + + it('preserva dominio number/step con min, max, step', () => { + const json = JSON.stringify(serializeCat(fullCat)); + const result = deserializeCat(JSON.parse(json)); + const sec = result.objs[0].secs[0]; + const att = sec.atts.find(a => a.id === 'at_potencia'); + const domain = att?.value.domain as any; + expect(domain.step).toBe(50); + expect(domain.max).toBe(500); + }); + + it('preserva acción attribute con ifTrue/ifFalse', () => { + const json = JSON.stringify(serializeCat(fullCat)); + const result = deserializeCat(JSON.parse(json)); + const action = result.objs[0].atts[1].rules!.rules[0].actions[0] as any; + expect(action.type).toBe('attribute'); + expect(action.ifTrue).toBe('required'); + expect(action.ifFalse).toBe('available'); + }); + + it('preserva acción value con AttValue embebido', () => { + const json = JSON.stringify(serializeCat(fullCat)); + const result = deserializeCat(JSON.parse(json)); + const actions = result.objs[0].secs[0].rules!.rules[0].actions; + const action = actions.find((a: any) => a.type === 'value') as any; + expect(action.value.domain.kind).toBe('number'); + expect(action.value.domain.fixed).toBe(0); + }); + + it('preserva acción domain con value y state', () => { + const json = JSON.stringify(serializeCat(fullCat)); + const result = deserializeCat(JSON.parse(json)); + const action = result.objs[0].rules!.rules[0].actions[0] as any; + expect(action.type).toBe('domain'); + expect(action.value).toBe('op_azul'); + expect(action.state).toBe('forbidden'); + }); + + it('preserva acción validation con level y message', () => { + const json = JSON.stringify(serializeCat(fullCat)); + const result = deserializeCat(JSON.parse(json)); + const action = result.objs[0].atts[1].rules!.rules[0].actions[1] as any; + expect(action.type).toBe('validation'); + expect(action.level).toBe('warning'); + expect(action.message).toBe('Rojo tiene suplemento'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// ERRORES DE DESERIALIZACIÓN +// ───────────────────────────────────────────────────────────────────────────── + +describe('deserializeCat — errores', () => { + it('lanza CatDeserializationError si el input no es objeto', () => { + expect(() => deserializeCat('not an object')).toThrow(CatDeserializationError); + }); + + it('lanza si falta id', () => { + const raw = { name: 'X', optCategories: [], opts: [], atts: [], objs: [] }; + expect(() => deserializeCat(raw)).toThrow(CatDeserializationError); + }); + + it('lanza si falta name', () => { + const raw = { id: 'ct_x', optCategories: [], opts: [], atts: [], objs: [] }; + expect(() => deserializeCat(raw)).toThrow(CatDeserializationError); + }); + + it('lanza si objs no es array', () => { + const raw = { id: 'ct_x', name: 'X', optCategories: [], opts: [], atts: [], objs: 'bad' }; + expect(() => deserializeCat(raw)).toThrow(CatDeserializationError); + }); + + it('lanza si domain.kind es desconocido', () => { + const raw = { + id: 'ct_x', name: 'X', optCategories: [], opts: [], objs: [], + atts: [{ id: 'at_x', name: 'X', value: { domain: { kind: 'unknown', mode: 'input' }, value: '' } }], + }; + expect(() => deserializeCat(raw)).toThrow(CatDeserializationError); + }); + + it('lanza si domain option tiene mode inválido', () => { + const raw = { + id: 'ct_x', name: 'X', optCategories: [], opts: [], objs: [], + atts: [{ id: 'at_x', name: 'X', value: { domain: { kind: 'option', mode: 'input' }, value: null } }], + }; + expect(() => deserializeCat(raw)).toThrow(CatDeserializationError); + }); + + it('lanza si action.type es desconocido', () => { + const raw = { + id: 'ct_x', name: 'X', optCategories: [], opts: [], atts: [], objs: [{ + id: 'ob:x', name: 'X', atts: [], secs: [], + rules: { rules: [{ id: 'rl_x', priority: 1, condition: true, actions: [{ type: 'unknown' }] }] }, + }], + }; + expect(() => deserializeCat(raw)).toThrow(CatDeserializationError); + }); + + it('el mensaje de error incluye el path del campo inválido', () => { + const raw = { id: 'ct_x', name: 'X', optCategories: [], opts: [], atts: [], objs: 'bad' }; + try { + deserializeCat(raw); + expect.fail('debería haber lanzado'); + } catch (e) { + expect(e).toBeInstanceOf(CatDeserializationError); + expect((e as CatDeserializationError).message).toContain('objs'); + } + }); + + it('acumula múltiples errores en lugar de parar en el primero', () => { + // Sin id ni name — debe reportar ambos + const raw = { optCategories: [], opts: [], atts: [], objs: [] }; + try { + deserializeCat(raw); + expect.fail('debería haber lanzado'); + } catch (e) { + expect(e).toBeInstanceOf(CatDeserializationError); + expect((e as CatDeserializationError).issues.length).toBeGreaterThanOrEqual(2); + } + }); +}); diff --git a/src/vcen/test/engine.bench.ts b/src/vcen/test/engine.bench.ts new file mode 100644 index 0000000..2eca43f --- /dev/null +++ b/src/vcen/test/engine.bench.ts @@ -0,0 +1,208 @@ +/** + * Benchmark: buildJsonLogicData — antes vs después de la optimización nivel 1 + * + * Ejecutar con: + * npx vitest bench src/vcen/test/engine.bench.ts + */ +import { bench, describe } from 'vitest'; +import type { Cat, Obj, Att } from '../lib/types'; +import { NODE_SEPARATOR } from '../lib/node_util.ts'; +import { evaluateCat } from '../lib/engine'; + +// ───────────────────────────────────────────────────────────────────────────── +// FIXTURES +// ───────────────────────────────────────────────────────────────────────────── + +function makeAtt(id: string, value: string, depId?: string): Att { + const rules = depId ? [{ + id: `r_${id}`, + priority: 10, + condition: { '==': [{ var: depId }, 'trigger'] }, + actions: [{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }], + }] : undefined; + + return { + id, + value: { domain: { kind: 'string', mode: 'input' }, value }, + rules: rules ? { rules } : undefined, + } as unknown as Att; +} + +function makeObj(id: string, attCount: number): Obj { + const atts: Att[] = [makeAtt(`at_trigger_${id}`, 'no')]; + for (let i = 1; i < attCount; i++) { + atts.push(makeAtt(`at_${id}_${i}`, `val_${i}`, `at_trigger_${id}`)); + } + return { id, atts, secs: [], rules: undefined } as unknown as Obj; +} + +function makeCat(objCount: number, attsPerObj: number): Cat { + const objs: Obj[] = []; + for (let i = 0; i < objCount; i++) objs.push(makeObj(`ob_${i}`, attsPerObj)); + return { id: 'ct_bench', atts: [], objs, secs: [], opts: [], optCategories: [] } as unknown as Cat; +} + +// ───────────────────────────────────────────────────────────────────────────── +// IMPLEMENTACIÓN "ANTES" — árbol reconstruido por cada llamada a applyRuleSet +// ───────────────────────────────────────────────────────────────────────────── + +function setNested(obj: Record, parts: string[], value: unknown) { + let current = obj; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (current[part] == null || typeof current[part] !== 'object') current[part] = {}; + current = current[part] as Record; + } + current[parts[parts.length - 1]] = value; +} + +function buildJsonLogicDataBefore(state: Record, prefix: string): Record { + const data: Record = {}; + const prefixParts = prefix.split(NODE_SEPARATOR).filter(Boolean); + for (const [key, value] of Object.entries(state)) { + const parts = key.split(NODE_SEPARATOR).filter(Boolean); + setNested(data, parts, value); + if (parts.length > prefixParts.length) { + const relative = parts.slice(prefixParts.length); + for (let i = 0; i < relative.length; i++) setNested(data, relative.slice(i), value); + } + } + return data; +} + +function simulateBefore(cat: Cat): void { + const state: Record = {}; + for (const obj of cat.objs) + for (const att of obj.atts) + state[`${cat.id}/${obj.id}/${att.id}`] = (att as any).value.value; + + for (const obj of cat.objs) { + const objPath = `${cat.id}/${obj.id}`; + for (const att of obj.atts) + if ((att as any).rules) buildJsonLogicDataBefore(state, objPath); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// IMPLEMENTACIÓN "DESPUÉS" — árbol construido una vez, spread por llamada +// ───────────────────────────────────────────────────────────────────────────── + +function buildNestedState(state: Record): Record { + const root: Record = {}; + for (const [key, value] of Object.entries(state)) { + const parts = key.split(NODE_SEPARATOR).filter(Boolean); + let current = root; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (current[part] == null || typeof current[part] !== 'object') current[part] = {}; + current = current[part] as Record; + } + current[parts[parts.length - 1]] = value; + } + return root; +} + +function buildJsonLogicDataAfter( + nestedState: Record, + prefix: string, +): Record { + let localScope: Record = nestedState; + for (const part of prefix.split(NODE_SEPARATOR).filter(Boolean)) { + const next = localScope[part]; + localScope = (next != null && typeof next === 'object') ? next as Record : {}; + } + return { ...nestedState, ...localScope }; +} + +function simulateAfter(cat: Cat): void { + const state: Record = {}; + for (const obj of cat.objs) + for (const att of obj.atts) + state[`${cat.id}/${obj.id}/${att.id}`] = (att as any).value.value; + + const nestedState = buildNestedState(state); // UNA SOLA VEZ + + for (const obj of cat.objs) { + const objPath = `${cat.id}/${obj.id}`; + for (const att of obj.atts) + if ((att as any).rules) buildJsonLogicDataAfter(nestedState, objPath); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// BENCHMARKS +// ───────────────────────────────────────────────────────────────────────────── + +const catSmall = makeCat(10, 5); // 50 atts, 40 con reglas +const catMedium = makeCat(30, 10); // 300 atts, 270 con reglas +const catLarge = makeCat(100, 20); // 2000 atts, 1900 con reglas + +describe('buildJsonLogicData — pequeño (50 atts)', () => { + bench('ANTES — árbol por llamada', () => { simulateBefore(catSmall); }); + bench('DESPUÉS — árbol pre-calculado', () => { simulateAfter(catSmall); }); +}); + +describe('buildJsonLogicData — mediano (300 atts)', () => { + bench('ANTES — árbol por llamada', () => { simulateBefore(catMedium); }); + bench('DESPUÉS — árbol pre-calculado', () => { simulateAfter(catMedium); }); +}); + +describe('buildJsonLogicData — grande (2000 atts)', () => { + bench('ANTES — árbol por llamada', () => { simulateBefore(catLarge); }); + bench('DESPUÉS — árbol pre-calculado', () => { simulateAfter(catLarge); }); +}); + +describe('evaluateCat completo — mediano (30 objs × 10 atts)', () => { + bench('evaluateCat con optimización nivel 1', () => { evaluateCat(catMedium); }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// VECTOR 3 — Draft vs Spread en merge*Actions +// ───────────────────────────────────────────────────────────────────────────── + +interface MockEffectiveState { + state: string; + forbiddenValues: string[]; + messages: { level: string; message: string }[]; + derivedValue?: unknown; +} + +const ACTIONS_PER_RULE = 5; +const RULES_PER_ATT = 3; +const ATTS = 300; + +// Simula el patrón ANTES: spread por cada acción +function applyActionSpread(current: MockEffectiveState, i: number): MockEffectiveState { + if (i % 3 === 0) return { ...current, state: 'required' }; + if (i % 3 === 1) return { ...current, forbiddenValues: [...current.forbiddenValues, `val_${i}`] }; + return { ...current, messages: [...current.messages, { level: 'error', message: `msg_${i}` }] }; +} + +// Simula el patrón DESPUÉS: draft mutable, un solo objeto +function applyActionDraft(draft: MockEffectiveState, i: number): void { + if (i % 3 === 0) { draft.state = 'required'; return; } + if (i % 3 === 1) { draft.forbiddenValues = [...draft.forbiddenValues, `val_${i}`]; return; } + draft.messages = [...draft.messages, { level: 'error', message: `msg_${i}` }]; +} + +describe(`merge*Actions — ${ATTS} atts × ${RULES_PER_ATT} reglas × ${ACTIONS_PER_RULE} acciones`, () => { + bench('ANTES — spread por acción', () => { + for (let a = 0; a < ATTS; a++) { + let state: MockEffectiveState = { state: 'available', forbiddenValues: [], messages: [] }; + for (let r = 0; r < RULES_PER_ATT; r++) + for (let i = 0; i < ACTIONS_PER_RULE; i++) + state = applyActionSpread(state, i); + } + }); + + bench('DESPUÉS — draft mutable', () => { + for (let a = 0; a < ATTS; a++) { + const draft: MockEffectiveState = { state: 'available', forbiddenValues: [], messages: [] }; + for (let r = 0; r < RULES_PER_ATT; r++) + for (let i = 0; i < ACTIONS_PER_RULE; i++) + applyActionDraft(draft, i); + // flush: en el engine real esto es effectiveState.set(path, draft) + const _result = draft; + } + }); +});