dev 7 months ago
parent dd34cbf9f1
commit 41f07e6605

@ -1,14 +1,15 @@
<script lang="ts"> <script lang="ts">
import './app.css'; import './app.css';
import ConditionBuilderDemo from "@/vcen/editor/comps/demos/ConditionBuilderDemo.svelte"; import ConditionBuilderDemo from "@/vcen/editor/demos/ConditionBuilderDemo.svelte";
import CatalogDemo from "@/vcen/editor/comps/demos/CatalogDemo.svelte"; import CatalogDemo from "@/vcen/editor/demos/CatalogDemo.svelte";
import Editor from "@/vcen/editor/canvas/Editor.svelte";
</script> </script>
<main> <main>
<CatalogDemo /> <Editor />
</main> </main>

@ -0,0 +1,204 @@
<script lang="ts">
import {createCatalogEditorStore} from "@/vcen/editor/stores/catalog-editor.svelte.ts";
import {type Cat, toCatID} from "@/vcen/lib";
import CatalogTree from "@/vcen/editor/comps/CatalogTree.svelte";
import CatalogEditor from "@/vcen/editor/comps/CatalogEditor.svelte";
const cat = {
id: toCatID('test'),
name: 'Nuevo',
optCategories: [],
opts: [],
atts: [],
objs: [],
}
</script>
<div>
<CatalogEditor cat={cat} />
</div>
<style>
/* ── Utilidades globales del editor ─────────────────────────────────── */
:global(.field-input) {
width: 100%;
background: #111827;
border: 1px solid #1f2937;
border-radius: 0.375rem;
padding: 0.375rem 0.625rem;
font-size: 0.8125rem;
color: #e2e8f0;
outline: none;
transition: border-color 120ms;
font-family: inherit;
}
:global(.field-input:focus) {
border-color: #4f46e5;
}
:global(.field-input.border-red-800) {
border-color: #991b1b;
}
:global(select.field-input) {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
padding-right: 1.75rem;
}
:global(textarea.field-input) {
resize: vertical;
}
:global(.section-title) {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6b7280;
padding-bottom: 0.5rem;
border-bottom: 1px solid #1f2937;
}
:global(.section-title-inline) {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6b7280;
}
/* ── Editor root ─────────────────────────────────────────────────────── */
.editor {
display: flex;
height: 100vh;
background: #0a0f1a;
color: #e2e8f0;
font-family: 'IBM Plex Sans', system-ui, sans-serif;
}
/* Nav */
.editor-nav {
width: 11rem;
shrink: 0;
border-right: 1px solid #1e293b;
display: flex;
flex-direction: column;
background: #080d17;
}
.editor-nav-header {
padding: 1.25rem 1rem 1rem;
border-bottom: 1px solid #1e293b;
display: flex;
align-items: baseline;
gap: 0.375rem;
}
.editor-logo {
font-size: 0.9375rem;
font-weight: 700;
letter-spacing: 0.1em;
color: #6366f1;
}
.editor-logo-sub {
font-size: 0.6875rem;
color: #334155;
letter-spacing: 0.05em;
}
/* Tabs */
.editor-tabs {
list-style: none;
margin: 0;
padding: 0.5rem 0;
flex: 1;
}
.editor-tab {
width: 100%;
text-align: left;
padding: 0.5rem 1rem;
font-size: 0.8125rem;
color: #475569;
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: color 120ms, background 120ms;
border-left: 2px solid transparent;
}
.editor-tab:hover {
color: #94a3b8;
background: rgb(255 255 255 / 0.02);
}
.editor-tab-active {
color: #a5b4fc;
border-left-color: #6366f1;
background: rgb(99 102 241 / 0.08);
}
.editor-tab-error {
font-size: 0.625rem;
padding: 0.1rem 0.4rem;
border-radius: 9999px;
background: rgb(239 68 68 / 0.2);
color: #fca5a5;
font-weight: 600;
}
/* Footer */
.editor-nav-footer {
padding: 0.75rem 1rem;
border-top: 1px solid #1e293b;
}
.editor-status {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.6875rem;
}
.editor-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
shrink: 0;
}
.editor-status-error { color: #f87171; }
.editor-status-error .editor-status-dot { background: #ef4444; }
.editor-status-warn { color: #fbbf24; }
.editor-status-warn .editor-status-dot { background: #f59e0b; }
.editor-status-ok { color: #4ade80; }
.editor-status-ok .editor-status-dot { background: #22c55e; }
/* Main */
.editor-main {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
</style>

@ -0,0 +1,365 @@
<script lang="ts">
import type { CatalogEditorStore } from '../stores/catalog-editor.svelte';
import type { AttDomain , Obj, Sec } from '@/vcen/lib';
import RuleSetEditor from './RuleSetEditor.svelte';
interface Props { editor: CatalogEditorStore; }
let { editor }: Props = $props();
// ── Contexto activo ───────────────────────────────────────────────────────
const context = $derived.by(() => {
const sel = editor.selection;
if (sel.kind !== 'att') return null;
const { objId, secId, attId } = sel;
const att = editor.findAtt(objId, secId, attId);
if (!att) return null;
let container: Obj | Sec | null = null;
if (secId) container = editor.findSec(objId, secId) ?? null;
else if (objId) container = editor.findObj(objId) ?? null;
return { att, objId, secId, attId, container };
});
// ── Nombre ────────────────────────────────────────────────────────────────
let nameInput = $state('');
$effect(() => { if (context) nameInput = context.att.name; });
function onNameBlur() {
if (!context || nameInput === context.att.name) return;
editor.renameAtt(context.objId, context.secId, context.attId, nameInput.trim() || context.att.name);
}
// ── Dominio ───────────────────────────────────────────────────────────────
const DOMAIN_KINDS = [
{ kind: 'string', label: 'Texto' },
{ kind: 'number', label: 'Número' },
{ kind: 'currency', label: 'Moneda' },
{ kind: 'boolean', label: 'Booleano' },
{ kind: 'option', label: 'Opción' },
{ kind: 'date', label: 'Fecha' },
{ kind: 'time', label: 'Hora' },
{ kind: 'datetime', label: 'Fecha y hora' },
] as const;
function onDomainKindChange(kind: string) {
if (!context) return;
editor.updateAttDomain(context.objId, context.secId, context.attId,
defaultDomainForKind(kind as AttDomain['kind']));
}
// ── Valor por defecto ─────────────────────────────────────────────────────
let defaultVal = $state<unknown>('');
$effect(() => { if (context) defaultVal = context.att.value.value; });
function saveDefault(raw: unknown) {
if (!context) return;
const kind = context.att.value.domain.kind;
let v = raw;
if (kind === 'number' || kind === 'currency') v = Number(raw) || 0;
if (kind === 'boolean') v = raw === 'true' || raw === true;
editor.updateAttDefaultValue(context.objId, context.secId, context.attId, v);
defaultVal = v;
}
// ── Opts para dominio option ──────────────────────────────────────────────
const availableOpts = $derived(editor.cat.opts);
const attOptIds = $derived.by(() => {
if (!context) return new Set<string>();
const d = context.att.value.domain;
if (d.kind !== 'option' || d.mode !== 'list') return new Set<string>();
return new Set(((d as any).list ?? []).map((ao: any) => ao.optId as string));
});
function toggleAttOpt(optId: string) {
if (!context) return;
if (attOptIds.has(optId)) editor.removeAttOpt(context.objId, context.secId, context.attId, optId as any);
else editor.addAttOpt (context.objId, context.secId, context.attId, optId as any);
}
// ── RuleSet ───────────────────────────────────────────────────────────────
const ruleSet = $derived(context?.att.rules ?? null);
function ensureRuleSet() {
if (!context || context.att.rules) return;
editor.setAttRuleSet(context.objId, context.secId, context.attId, { rules: [] });
}
function removeRuleSet() {
if (!context) return;
editor.setAttRuleSet(context.objId, context.secId, context.attId, undefined);
}
</script>
{#if !context}
<div class="flex items-center justify-center h-full text-sm text-gray-600">
Selecciona un atributo
</div>
{:else}
{@const { att, objId, secId, attId } = context}
<div class="flex flex-col h-full overflow-y-auto">
<!-- ── Cabecera ───────────────────────────────────────────────────── -->
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-800 shrink-0">
<div class="flex flex-col gap-0.5">
<span class="text-[11px] text-gray-600 uppercase tracking-wider">Atributo</span>
<span class="text-sm font-semibold text-gray-100">{att.name}</span>
</div>
<button
onclick={() => editor.removeAtt(objId, secId, attId)}
class="text-xs text-red-600 hover:text-red-400 transition-colors"
>Eliminar</button>
</div>
<!-- ── Cuerpo ─────────────────────────────────────────────────────── -->
<div class="flex flex-col gap-6 px-5 py-5">
<!-- Nombre -->
<div class="flex flex-col gap-1.5">
<label class="section-title-inline">Nombre</label>
<input
type="text"
bind:value={nameInput}
onblur={onNameBlur}
onkeydown={e => { if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur(); }}
class="field-input"
/>
</div>
<!-- Tipo de dominio -->
<div class="flex flex-col gap-1.5">
<label class="section-title-inline">Tipo</label>
<select
value={att.value.domain.kind}
onchange={e => onDomainKindChange(e.currentTarget.value)}
class="field-input"
>
{#each DOMAIN_KINDS as { kind, label }}
<option value={kind}>{label}</option>
{/each}
</select>
</div>
<!-- Config extra por dominio -->
{#if att.value.domain.kind === 'string'}
{@const d = att.value.domain as any}
<div class="flex flex-col gap-1.5">
<label class="section-title-inline">Modo texto</label>
<select value={d.mode}
onchange={e => editor.updateAttDomain(objId, secId, attId,
{ kind: 'string', mode: e.currentTarget.value as any })}
class="field-input">
<option value="input">Libre</option>
<option value="list">Lista de valores</option>
<option value="pattern">Patrón (regex)</option>
<option value="fixed">Fijo</option>
</select>
{#if d.mode === 'list'}
<textarea
value={(d.list ?? []).join('\n')}
oninput={e => editor.updateAttDomain(objId, secId, attId,
{ kind: 'string', mode: 'list',
list: e.currentTarget.value.split('\n').map((s:string) => s.trim()).filter(Boolean) })}
placeholder="uno por línea"
rows={4}
class="field-input"
></textarea>
{:else if d.mode === 'pattern'}
<input type="text" value={d.pattern ?? ''}
oninput={e => 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'}
<input type="text" value={d.fixed ?? ''}
oninput={e => editor.updateAttDomain(objId, secId, attId,
{ kind: 'string', mode: 'fixed', fixed: e.currentTarget.value })}
class="field-input" />
{/if}
</div>
{:else if att.value.domain.kind === 'number' || att.value.domain.kind === 'currency'}
{@const d = att.value.domain as any}
<div class="flex flex-col gap-1.5">
<label class="section-title-inline">Modo numérico</label>
<select value={d.mode}
onchange={e => editor.updateAttDomain(objId, secId, attId,
{ kind: att.value.domain.kind, mode: e.currentTarget.value as any } as any)}
class="field-input">
<option value="input">Libre</option>
<option value="range">Rango</option>
<option value="step">Pasos</option>
<option value="list">Lista</option>
<option value="fixed">Fijo</option>
</select>
{#if d.mode === 'range' || d.mode === 'step'}
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<span class="text-[11px] text-gray-600">Mín</span>
<input type="number" value={d.min ?? 0}
oninput={e => editor.updateAttDomain(objId, secId, attId, { ...d, min: Number(e.currentTarget.value) })}
class="field-input" />
</div>
<div class="flex flex-col gap-1">
<span class="text-[11px] text-gray-600">Máx</span>
<input type="number" value={d.max ?? 100}
oninput={e => editor.updateAttDomain(objId, secId, attId, { ...d, max: Number(e.currentTarget.value) })}
class="field-input" />
</div>
</div>
{#if d.mode === 'step'}
<div class="flex flex-col gap-1">
<span class="text-[11px] text-gray-600">Paso</span>
<input type="number" value={d.step ?? 1}
oninput={e => editor.updateAttDomain(objId, secId, attId, { ...d, step: Number(e.currentTarget.value) })}
class="field-input" />
</div>
{/if}
{:else if d.mode === 'fixed'}
<input type="number" value={d.fixed ?? 0}
oninput={e => editor.updateAttDomain(objId, secId, attId,
{ kind: att.value.domain.kind, mode: 'fixed', fixed: Number(e.currentTarget.value) } as any)}
class="field-input" />
{/if}
</div>
{:else if att.value.domain.kind === 'option'}
<div class="flex flex-col gap-2">
<span class="section-title-inline">
Opciones disponibles
<span class="normal-case text-gray-700 ml-1">
({attOptIds.size} de {availableOpts.length})
</span>
</span>
{#if availableOpts.length === 0}
<p class="text-xs text-gray-600 italic">
No hay opciones globales — créalas en "Opciones globales"
</p>
{:else}
<div class="flex flex-col gap-0.5 rounded border border-gray-800 bg-gray-900 p-2">
{#each availableOpts as opt (opt.id)}
{@const checked = attOptIds.has(opt.id)}
<label class="flex items-center gap-2.5 px-2 py-1.5 rounded
hover:bg-gray-800 cursor-pointer transition-colors group">
<input type="checkbox" {checked}
onchange={() => toggleAttOpt(opt.id)}
class="accent-indigo-500 shrink-0" />
<span class="text-sm text-gray-300 flex-1 group-hover:text-gray-100">
{String(opt.value.value)}
</span>
{#if opt.categoryId}
<span class="text-[11px] text-gray-600">
{editor.cat.optCategories.find(c => c.id === opt.categoryId)?.name ?? ''}
</span>
{/if}
</label>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Valor por defecto -->
<div class="flex flex-col gap-1.5">
<label class="section-title-inline">Valor por defecto</label>
{#if att.value.domain.kind === 'boolean'}
<select value={String(defaultVal)}
onchange={e => saveDefault(e.currentTarget.value)}
class="field-input">
<option value="true">Sí (true)</option>
<option value="false">No (false)</option>
</select>
{:else if att.value.domain.kind === 'option'}
{@const d = att.value.domain as any}
<select value={String(defaultVal ?? '')}
onchange={e => saveDefault(e.currentTarget.value)}
class="field-input">
<option value="">— ninguno —</option>
{#each (d.list ?? []) as ao (ao.optId)}
{@const opt = availableOpts.find(o => o.id === ao.optId)}
{#if opt}
<option value={opt.id}>{String(opt.value.value)}</option>
{/if}
{/each}
</select>
{:else if att.value.domain.kind === 'number' || att.value.domain.kind === 'currency'}
<input type="number" value={String(defaultVal ?? 0)}
oninput={e => saveDefault(e.currentTarget.value)}
class="field-input" />
{:else if att.value.domain.kind === 'date'}
<input type="date" value={String(defaultVal ?? '')}
oninput={e => saveDefault(e.currentTarget.value)}
class="field-input" />
{:else if att.value.domain.kind === 'time'}
<input type="time" value={String(defaultVal ?? '')}
oninput={e => saveDefault(e.currentTarget.value)}
class="field-input" />
{:else if att.value.domain.kind === 'datetime'}
<input type="datetime-local" value={String(defaultVal ?? '')}
oninput={e => saveDefault(e.currentTarget.value)}
class="field-input" />
{:else}
<input type="text" value={String(defaultVal ?? '')}
oninput={e => saveDefault(e.currentTarget.value)}
class="field-input" />
{/if}
</div>
<!-- RuleSet -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between pb-2 border-b border-gray-800">
<span class="section-title" style="border:none;padding:0">Reglas</span>
{#if ruleSet}
<button onclick={removeRuleSet}
class="text-xs text-red-600 hover:text-red-400 transition-colors">
✕ Eliminar todas
</button>
{:else}
<button onclick={ensureRuleSet}
class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors">
+ Añadir reglas
</button>
{/if}
</div>
{#if ruleSet && context.container}
<RuleSetEditor
{ruleSet}
container={context.container}
catOpts={editor.cat.opts}
{editor}
/>
{/if}
</div>
</div>
</div>
{/if}
<script lang="ts" module>
import type { AttDomain as AD } from './att';
function defaultDomainForKind(kind: AD['kind']): AD {
switch (kind) {
case 'string': return { kind, mode: 'input' };
case 'number': return { kind, mode: 'input' };
case 'currency': return { kind, mode: 'input' };
case 'boolean': return { kind, mode: 'input' };
case 'option': return { kind, mode: 'list', list: [] };
case 'date': return { kind, mode: 'input' };
case 'time': return { kind, mode: 'input' };
case 'datetime': return { kind, mode: 'input' };
}
}
</script>

@ -0,0 +1,59 @@
<script lang="ts">
import type { CatalogEditorStore } from '../stores/catalog-editor.svelte.js';
interface Props { editor: CatalogEditorStore; }
let { editor }: Props = $props();
const cat = $derived(editor.cat);
let nameInput = $state('');
$effect(() => { nameInput = cat.name; });
function onNameBlur() {
if (nameInput === cat.name) return;
editor.renameCat(nameInput);
}
const inputCls = `w-full rounded border border-gray-700 bg-gray-900 px-3 py-2
text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`;
const labelCls = `text-xs font-semibold uppercase tracking-wider text-gray-500`;
</script>
<div class="flex flex-col gap-6 p-5">
<h2 class="text-base font-semibold text-gray-100">Catálogo</h2>
<div class="flex flex-col gap-1.5">
<label class={labelCls}>Nombre</label>
<input type="text" bind:value={nameInput} onblur={onNameBlur} class={inputCls} />
</div>
<div class="flex flex-col gap-1.5">
<span class={labelCls}>Resumen</span>
<div class="rounded border border-gray-800 bg-gray-900 p-4 grid grid-cols-2 gap-3 text-sm">
<div class="flex flex-col gap-0.5">
<span class="text-2xl font-bold text-gray-100">{cat.objs.length}</span>
<span class="text-gray-600">Objetos</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-2xl font-bold text-gray-100">
{cat.objs.reduce((n, o) => n + o.secs.length, 0)}
</span>
<span class="text-gray-600">Secciones</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-2xl font-bold text-gray-100">
{cat.objs.reduce((n, o) =>
n + o.atts.length + o.secs.reduce((m, s) => m + s.atts.length, 0), 0
)}
</span>
<span class="text-gray-600">Atributos</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-2xl font-bold text-gray-100">{cat.opts.length}</span>
<span class="text-gray-600">Opciones globales</span>
</div>
</div>
</div>
</div>

@ -0,0 +1,192 @@
<script lang="ts">
import type { Cat } from '@/vcen/lib';
import { createCatalogEditorStore } from '../stores/catalog-editor.svelte';
import CatalogTree from './CatalogTree.svelte';
import CatPanel from './CatPanel.svelte';
import ObjPanel from './ObjPanel.svelte';
import SecPanel from './SecPanel.svelte';
import AttPanel from './AttPanel.svelte';
import OptPanel from './OptPanel.svelte';
interface Props {
cat: Cat;
onsave?: (cat: Cat) => void;
oncancel?: () => void;
}
let { cat, onsave, oncancel }: Props = $props();
const editor = createCatalogEditorStore(cat);
const sel = $derived(editor.selection);
</script>
<div class="editor">
<!-- Árbol izquierdo -->
<div class="editor-tree">
<CatalogTree {editor} />
</div>
<!-- Panel derecho -->
<div class="editor-main">
<!-- Toolbar -->
<div class="editor-toolbar">
<span class="editor-breadcrumb">
{#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}
</span>
<div class="flex gap-2">
{#if oncancel}
<button onclick={oncancel} class="editor-btn">Cancelar</button>
{/if}
<button
onclick={() => { onsave?.(editor.getCat()); editor.markClean(); }}
disabled={!editor.dirty}
class="editor-btn editor-btn-primary"
class:editor-btn-disabled={!editor.dirty}
>Guardar</button>
</div>
</div>
<!-- Contenido -->
<div class="editor-panel">
{#if sel.kind === 'cat'} <CatPanel {editor} />
{:else if sel.kind === 'opts'} <OptPanel {editor} />
{:else if sel.kind === 'obj'} <ObjPanel {editor} />
{:else if sel.kind === 'sec'} <SecPanel {editor} />
{:else if sel.kind === 'att'} <AttPanel {editor} />
{/if}
</div>
</div>
</div>
<style>
/* ── Clases globales (field-input, section-title, etc.) ─────────────────
Deben vivir en el componente raíz para que los hijos las hereden. */
:global(.field-input) {
width: 100%;
background: #111827;
border: 1px solid #1f2937;
border-radius: 0.375rem;
padding: 0.375rem 0.625rem;
font-size: 0.8125rem;
color: #e2e8f0;
outline: none;
transition: border-color 120ms;
font-family: inherit;
}
:global(.field-input:focus) { border-color: #4f46e5; }
:global(select.field-input) {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
padding-right: 1.75rem;
}
:global(textarea.field-input) { resize: vertical; }
:global(.section-title) {
display: block;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6b7280;
padding-bottom: 0.5rem;
border-bottom: 1px solid #1f2937;
}
:global(.section-title-inline) {
display: block;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6b7280;
}
/* ── Layout ──────────────────────────────────────────────────────────── */
.editor {
display: flex;
height: 100%;
background: #0a0f1a;
color: #e2e8f0;
font-size: 0.8125rem;
font-family: 'IBM Plex Sans', system-ui, sans-serif;
overflow: hidden;
}
.editor-tree {
width: 15rem;
flex-shrink: 0;
border-right: 1px solid #1e293b;
display: flex;
flex-direction: column;
overflow: hidden;
}
.editor-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 1.25rem;
border-bottom: 1px solid #1e293b;
background: #080d17;
flex-shrink: 0;
}
.editor-breadcrumb {
font-size: 0.75rem;
color: #475569;
}
.editor-panel {
flex: 1;
overflow-y: auto;
}
/* ── Botones ─────────────────────────────────────────────────────────── */
.editor-btn {
padding: 0.3rem 0.875rem;
font-size: 0.8125rem;
border-radius: 0.375rem;
border: 1px solid #1f2937;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: color 120ms, border-color 120ms, background 120ms;
font-family: inherit;
}
.editor-btn:hover { color: #e2e8f0; border-color: #334155; }
.editor-btn-primary {
background: #4f46e5;
border-color: #4f46e5;
color: #fff;
}
.editor-btn-primary:hover { background: #4338ca; border-color: #4338ca; }
.editor-btn-disabled {
background: #1f2937 !important;
border-color: #1f2937 !important;
color: #374151 !important;
cursor: not-allowed !important;
}
</style>

@ -0,0 +1,268 @@
<script lang="ts">
import type { CatalogEditorStore, Selection } from '../stores/catalog-editor.svelte.ts';
import type { ObjID, SecID, AttID } from '@/vcen/lib';
interface Props { editor: CatalogEditorStore; }
let { editor }: Props = $props();
const cat = $derived(editor.cat);
const selection = $derived(editor.selection);
// ── Nodos expandidos ────────────────────────────────────────────────────
// $state independiente — se inicializa una vez con los ids actuales del cat.
// Cuando se añaden nuevos objs/secs se actualizan manualmente en addObj/addSec.
let expandedObjs = $state<Set<ObjID>>(new Set(editor.cat.objs.map(o => o.id)));
let expandedSecs = $state<Set<string>>(new Set(
editor.cat.objs.flatMap(o => o.secs.map(s => SEP(o.id, s.id)))
));
function toggleObj(objId: ObjID) {
const next = new Set(expandedObjs);
next.has(objId) ? next.delete(objId) : next.add(objId);
expandedObjs = next;
}
// Clave compuesta para secciones — misma lógica que nodeSeparator en engine.ts
function SEP(a: string, b: string) { return `${a}/${b}`; }
function toggleSec(objId: ObjID, secId: SecID) {
const key = SEP(objId, secId);
const next = new Set(expandedSecs);
next.has(key) ? next.delete(key) : next.add(key);
expandedSecs = next;
}
// ── Selección helpers ───────────────────────────────────────────────────
function isSelected(s: Selection): boolean {
const sel = selection;
if (sel.kind !== s.kind) return false;
if (s.kind === 'cat' || s.kind === 'opts') return true;
if (s.kind === 'obj') return sel.kind === 'obj' && sel.objId === s.objId;
if (s.kind === 'sec') return sel.kind === 'sec' && sel.objId === s.objId && sel.secId === s.secId;
if (s.kind === 'att') return sel.kind === 'att' && sel.attId === s.attId;
return false;
}
const selBase = `flex items-center gap-1.5 w-full rounded px-2 py-1 text-sm text-left
transition-colors cursor-pointer select-none`;
const selInact = `${selBase} text-gray-300 hover:bg-gray-800`;
const selAct = `${selBase} bg-blue-600 text-white`;
function cls(s: Selection) { return isSelected(s) ? selAct : selInact; }
// ── Add helpers con expansión automática ────────────────────────────────
function addObj() {
const id = editor.addObj();
expandedObjs = new Set([...expandedObjs, id]);
}
function addSec(objId: ObjID) {
const id = editor.addSec(objId);
expandedSecs = new Set([...expandedSecs, SEP(objId, id)]);
}
function getCatAttSel(attId: AttID): Selection {
return { kind: 'att', objId: '' as ObjID, attId };
}
</script>
<!-- ─────────────────────────────────────────────────────────────────────── -->
<!-- PANEL IZQUIERDO -->
<!-- ─────────────────────────────────────────────────────────────────────── -->
<nav class="flex flex-col h-full bg-gray-950 border-r border-gray-800 select-none">
<!-- Header del catálogo -->
<div class="flex items-center justify-between px-3 py-2.5 border-b border-gray-800">
<button
class={cls({ kind: 'cat' })}
onclick={() => editor.select({ kind: 'cat' })}
>
<span class="text-xs text-gray-500">📁</span>
<span class="font-medium truncate">{cat.name}</span>
</button>
</div>
<!-- Árbol scrolleable -->
<div class="flex-1 overflow-y-auto py-1">
<!-- Opts globales -->
<button
class="{cls({ kind: 'opts' })} mx-1 my-0.5"
onclick={() => editor.select({ kind: 'opts' })}
>
<span class="text-xs">🏷️</span>
<span>Opciones globales</span>
<span class="ml-auto text-xs opacity-50">{cat.opts.length}</span>
</button>
<!-- Atts de catálogo (si los hay) -->
{#if cat.atts.length > 0}
<div class="mt-1 mb-0.5 px-3">
<span class="text-xs font-semibold uppercase tracking-wider text-gray-600">
Atributos globales
</span>
</div>
{#each cat.atts as att (att.id)}
{@const catAttSel = getCatAttSel(att.id)}
<button
class="{cls(catAttSel)} mx-1 pl-5"
onclick={() => editor.select(catAttSel)}
>
<span class="text-xs opacity-50">⬡</span>
<span class="truncate">{att.name}</span>
</button>
{/each}
{/if}
<!-- Divider + label objetos -->
<div class="flex items-center justify-between px-3 mt-2 mb-0.5">
<span class="text-xs font-semibold uppercase tracking-wider text-gray-600">
Objetos
</span>
<button
onclick={addObj}
class="text-xs text-gray-500 hover:text-blue-400 transition-colors"
title="Añadir objeto"
>+</button>
</div>
<!-- Lista de objs -->
{#each cat.objs as obj (obj.id)}
{@const objExpanded = expandedObjs.has(obj.id)}
<!-- Obj row -->
<div class="flex items-center gap-0.5 mx-1 my-0.5">
<!-- Chevron expand/collapse -->
<button
onclick={() => toggleObj(obj.id)}
class="shrink-0 w-5 h-5 flex items-center justify-center rounded
text-gray-600 hover:text-gray-300 transition-colors"
>
<span class="text-xs">{objExpanded ? '▾' : '▸'}</span>
</button>
<!-- Nombre obj -->
<button
class="flex-1 {cls({ kind: 'obj', objId: obj.id })} py-0.5"
onclick={() => editor.select({ kind: 'obj', objId: obj.id })}
>
<span class="text-xs">◈</span>
<span class="truncate">{obj.name}</span>
</button>
</div>
<!-- Contenido del obj (colapsable) -->
{#if objExpanded}
<div class="pl-5">
<!-- Atts directos del obj -->
{#each obj.atts as att (att.id)}
<button
class="{cls({ kind: 'att', objId: obj.id, attId: att.id })} mx-1 my-0.5 pl-3"
onclick={() => editor.select({ kind: 'att', objId: obj.id, attId: att.id })}
>
<span class="text-xs opacity-50">⬡</span>
<span class="truncate">{att.name}</span>
</button>
{/each}
<!-- Botón añadir att directo -->
<button
onclick={() => editor.addAtt(obj.id, undefined)}
class="mx-1 my-0.5 pl-3 flex items-center gap-1 text-xs text-gray-600
hover:text-blue-400 transition-colors w-full py-0.5"
>
<span>+</span> att
</button>
<!-- Secs -->
{#each obj.secs as sec (sec.id)}
{@const secKey = SEP(obj.id, sec.id)}
{@const secExpanded = expandedSecs.has(secKey)}
<!-- Sec row -->
<div class="flex items-center gap-0.5 mx-1 my-0.5">
<button
onclick={() => toggleSec(obj.id, sec.id)}
class="shrink-0 w-5 h-5 flex items-center justify-center rounded
text-gray-600 hover:text-gray-300 transition-colors"
>
<span class="text-xs">{secExpanded ? '▾' : '▸'}</span>
</button>
<button
class="flex-1 {cls({ kind: 'sec', objId: obj.id, secId: sec.id })} py-0.5"
onclick={() => editor.select({ kind: 'sec', objId: obj.id, secId: sec.id })}
>
<span class="text-xs opacity-60">▤</span>
<span class="truncate">{sec.name}</span>
</button>
</div>
<!-- Atts de la sec -->
{#if secExpanded}
<div class="pl-5">
{#each sec.atts as att (att.id)}
<button
class="{cls({ kind: 'att', objId: obj.id, secId: sec.id, attId: att.id })} mx-1 my-0.5 pl-3"
onclick={() => editor.select({
kind: 'att', objId: obj.id, secId: sec.id, attId: att.id
})}
>
<span class="text-xs opacity-50">⬡</span>
<span class="truncate">{att.name}</span>
</button>
{/each}
<!-- Botón añadir att en sec -->
<button
onclick={() => editor.addAtt(obj.id, sec.id)}
class="mx-1 my-0.5 pl-3 flex items-center gap-1 text-xs text-gray-600
hover:text-blue-400 transition-colors w-full py-0.5"
>
<span>+</span> att
</button>
</div>
{/if}
{/each}
<!-- Botón añadir sec -->
<button
onclick={() => addSec(obj.id)}
class="mx-1 my-0.5 pl-3 flex items-center gap-1 text-xs text-gray-600
hover:text-blue-400 transition-colors w-full py-0.5"
>
<span>+</span> sección
</button>
</div>
{/if}
{/each}
<!-- Botón añadir obj (al final de la lista) -->
{#if cat.objs.length === 0}
<button
onclick={addObj}
class="mx-2 mt-1 flex items-center gap-1.5 text-sm text-gray-600
hover:text-blue-400 transition-colors"
>
<span>+</span> Añadir primer objeto
</button>
{/if}
</div>
<!-- Footer: indicador de cambios sin guardar -->
{#if editor.dirty}
<div class="px-3 py-2 border-t border-gray-800 text-xs text-amber-400 flex items-center gap-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 inline-block"></span>
Cambios sin guardar
</div>
{/if}
</nav>

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { ConditionBuilderStore } from '../stores/ConditionEditor.svelte.ts'; import type { ConditionBuilderStore } from '../stores/condition-builder.svelte.ts';
import type {CompareNode, Operand} from '../lib/condition_editor.ts'; import type {CompareNode, Operand} from '../lib/condition-editor.ts';
interface Props { interface Props {
node: CompareNode; node: CompareNode;

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { ConditionBuilderStore } from '../stores/ConditionEditor.svelte.ts'; import type { ConditionBuilderStore } from '../stores/condition-builder.svelte.ts';
import ConditionNode from './ConditionNode.svelte'; import ConditionNode from './ConditionNode.svelte';
interface Props { interface Props {

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { ConditionBuilderStore } from '../stores/ConditionEditor.svelte'; import type { ConditionBuilderStore } from '../stores/condition-builder.svelte.ts';
import type { ConditionNode as ConditionNodeType } from '../lib/condition_editor'; import type { ConditionNode as ConditionNodeType } from '../lib/condition-editor.ts';
import LogicNodeEditor from './LogicNodeEditor.svelte'; import LogicNodeEditor from './LogicNodeEditor.svelte';
import CompareNodeEditor from './CompareNodeEditor.svelte'; import CompareNodeEditor from './CompareNodeEditor.svelte';

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { ConditionBuilderStore } from '../stores/ConditionEditor.svelte.ts'; import type { ConditionBuilderStore } from '../stores/condition-builder.svelte.ts';
import type {LogicNode, LogicOp} from '../lib/condition_editor.ts'; import type {LogicNode, LogicOp} from '../lib/condition-editor.ts';
import ConditionNode from './ConditionNode.svelte'; import ConditionNode from './ConditionNode.svelte';
interface Props { interface Props {

@ -0,0 +1,118 @@
<script lang="ts">
import type { CatalogEditorStore } from '../stores/catalog-editor.svelte.js';
import RuleSetEditor from './RuleSetEditor.svelte';
interface Props { editor: CatalogEditorStore; }
let { editor }: Props = $props();
const sel = $derived(editor.selection);
const obj = $derived(sel.kind === 'obj' ? editor.findObj(sel.objId) ?? null : null);
let nameInput = $state('');
$effect(() => { if (obj) nameInput = obj.name; });
function onNameBlur() {
if (!obj || !sel || sel.kind !== 'obj' || nameInput === obj.name) return;
editor.renameObj(sel.objId, nameInput);
}
function ensureRuleSet() {
if (!obj || sel.kind !== 'obj' || obj.rules) return;
editor.setObjRuleSet(sel.objId, { rules: [] });
}
function removeRuleSet() {
if (sel.kind !== 'obj') return;
editor.setObjRuleSet(sel.objId, undefined);
}
const inputCls = `w-full rounded border border-gray-700 bg-gray-900 px-3 py-2
text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`;
const labelCls = `text-xs font-semibold uppercase tracking-wider text-gray-500`;
</script>
{#if !obj || sel.kind !== 'obj'}
<div class="flex items-center justify-center h-40 text-sm text-gray-600">
Selecciona un objeto
</div>
{:else}
<div class="flex flex-col gap-6 p-5">
<!-- Header -->
<div class="flex items-center justify-between">
<h2 class="text-base font-semibold text-gray-100">{obj.name}</h2>
<button
onclick={() => editor.removeObj(sel.objId)}
class="text-xs text-red-500 hover:text-red-400 transition-colors"
>Eliminar objeto</button>
</div>
<!-- Nombre -->
<div class="flex flex-col gap-1.5">
<label class={labelCls}>Nombre</label>
<input type="text" bind:value={nameInput} onblur={onNameBlur} class={inputCls} />
</div>
<!-- Resumen de estructura -->
<div class="flex flex-col gap-1.5">
<span class={labelCls}>Estructura</span>
<div class="rounded border border-gray-800 bg-gray-900 p-3 flex gap-6 text-sm">
<span class="text-gray-400">
<span class="text-gray-100 font-medium">{obj.atts.length}</span> atts directos
</span>
<span class="text-gray-400">
<span class="text-gray-100 font-medium">{obj.secs.length}</span> secciones
</span>
<span class="text-gray-400">
<span class="text-gray-100 font-medium">
{obj.secs.reduce((n, s) => n + s.atts.length, 0)}
</span> atts en secciones
</span>
</div>
</div>
<!-- Orden objs -->
<div class="flex flex-col gap-1.5">
<span class={labelCls}>Posición</span>
<div class="flex gap-2">
<button
onclick={() => editor.moveObj(sel.objId, 'up')}
class="rounded border border-gray-700 px-3 py-1.5 text-sm text-gray-400
hover:text-gray-100 hover:border-gray-500 transition-colors"
>↑ Subir</button>
<button
onclick={() => editor.moveObj(sel.objId, 'down')}
class="rounded border border-gray-700 px-3 py-1.5 text-sm text-gray-400
hover:text-gray-100 hover:border-gray-500 transition-colors"
>↓ Bajar</button>
</div>
</div>
<!-- RuleSet -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class={labelCls}>Reglas</span>
{#if obj.rules}
<button onclick={removeRuleSet}
class="text-xs text-red-500 hover:text-red-400 transition-colors">
✕ Eliminar reglas
</button>
{:else}
<button onclick={ensureRuleSet}
class="text-xs text-blue-400 hover:text-blue-300 transition-colors">
+ Añadir reglas
</button>
{/if}
</div>
{#if obj.rules}
<RuleSetEditor
ruleSet={obj.rules}
container={obj}
catOpts={editor.cat.opts}
{editor}
/>
{/if}
</div>
</div>
{/if}

@ -0,0 +1,246 @@
<script lang="ts">
import type { CatalogEditorStore } from '../stores/catalog-editor.svelte.js';
import type { OptID, OptCatID } from '@/vcen/lib';
interface Props { editor: CatalogEditorStore; }
let { editor }: Props = $props();
const cat = $derived(editor.cat);
// ── Edición inline de opt ─────────────────────────────────────────────────
let editingOptId = $state<OptID | null>(null);
let editingValue = $state('');
let editingLabel = $state('');
let editingCatId = $state<OptCatID | ''>('');
function startEdit(optId: OptID) {
const opt = cat.opts.find(o => o.id === optId);
if (!opt) return;
editingOptId = optId;
editingValue = String(opt.value.value);
editingLabel = typeof opt.label === 'string'
? opt.label
: (opt.label as any)?.['es'] ?? '';
editingCatId = opt.categoryId ?? '';
}
function saveEdit() {
if (!editingOptId) return;
editor.updateOpt(editingOptId, {
value: { kind: 'string', value: editingValue },
label: editingLabel || undefined,
categoryId: (editingCatId as OptCatID) || undefined,
});
editingOptId = null;
}
function cancelEdit() { editingOptId = null; }
// ── Nueva categoría inline ────────────────────────────────────────────────
let newCatName = $state('');
function addCategory() {
if (!newCatName.trim()) return;
editor.addOptCategory(newCatName.trim());
newCatName = '';
}
// ── Edición inline de categoría ───────────────────────────────────────────
let editingCatEditId = $state<OptCatID | null>(null);
let editingCatEditName = $state('');
function startEditCat(catId: OptCatID) {
const oc = cat.optCategories.find(c => c.id === catId);
if (!oc) return;
editingCatEditId = catId;
editingCatEditName = typeof oc.name === 'string'
? oc.name
: (oc.name as any)?.['es'] ?? '';
}
function saveEditCat() {
if (!editingCatEditId || !editingCatEditName.trim()) return;
editor.renameOptCategory(editingCatEditId, editingCatEditName.trim());
editingCatEditId = null;
}
// ── Estilos ───────────────────────────────────────────────────────────────
const inputCls = `w-full rounded border border-gray-700 bg-gray-900 px-3 py-2
text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`;
const labelCls = `text-xs font-semibold uppercase tracking-wider text-gray-500`;
const btnSm = `rounded px-2.5 py-1 text-xs transition-colors`;
</script>
<div class="flex flex-col gap-6 p-5">
<!-- ── Categorías ───────────────────────────────────────────────────── -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class={labelCls}>Categorías</span>
</div>
{#if cat.optCategories.length === 0}
<p class="text-xs text-gray-600 italic">Sin categorías</p>
{:else}
<div class="flex flex-col gap-1">
{#each cat.optCategories as oc (oc.id)}
{#if editingCatEditId === oc.id}
<div class="flex items-center gap-2">
<input
type="text"
bind:value={editingCatEditName}
onkeydown={e => { 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"
/>
<button onclick={saveEditCat}
class="{btnSm} bg-blue-600 text-white hover:bg-blue-500">✓</button>
<button onclick={() => editingCatEditId = null}
class="{btnSm} bg-gray-800 text-gray-400 hover:text-gray-200">✕</button>
</div>
{:else}
<div class="flex items-center gap-2 rounded border border-gray-800
bg-gray-900 px-3 py-2 group">
<span class="flex-1 text-sm text-gray-300">
{typeof oc.name === 'string' ? oc.name : (oc.name as any)?.['es'] ?? oc.id}
</span>
<span class="text-xs text-gray-600 mr-2">
{cat.opts.filter(o => o.categoryId === oc.id).length} opts
</span>
<button onclick={() => startEditCat(oc.id)}
class="{btnSm} text-gray-600 hover:text-blue-400 opacity-0 group-hover:opacity-100">
✎
</button>
<button onclick={() => editor.removeOptCategory(oc.id)}
class="{btnSm} text-gray-600 hover:text-red-400 opacity-0 group-hover:opacity-100">
✕
</button>
</div>
{/if}
{/each}
</div>
{/if}
<!-- Añadir categoría -->
<div class="flex gap-2 mt-1">
<input
type="text"
bind:value={newCatName}
placeholder="Nueva categoría..."
onkeydown={e => { 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"
/>
<button
onclick={addCategory}
disabled={!newCatName.trim()}
class="{btnSm} bg-gray-800 text-gray-300 hover:bg-gray-700
disabled:opacity-30 disabled:cursor-not-allowed px-3 py-1.5"
>+ Categoría</button>
</div>
</div>
<!-- ── Opciones ──────────────────────────────────────────────────────── -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class={labelCls}>Opciones <span class="normal-case text-gray-600 ml-1">({cat.opts.length})</span></span>
<button
onclick={() => editor.addOpt()}
class="{btnSm} bg-blue-600 text-white hover:bg-blue-500 px-3 py-1.5"
>+ Opción</button>
</div>
{#if cat.opts.length === 0}
<p class="text-xs text-gray-600 italic">Sin opciones — pulsa "+ Opción" para crear una</p>
{:else}
<div class="flex flex-col gap-1">
{#each cat.opts as opt (opt.id)}
{#if editingOptId === opt.id}
<!-- Fila edición -->
<div class="flex flex-col gap-2 rounded border border-blue-700
bg-gray-900 p-3">
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">Valor</span>
<input type="text" bind:value={editingValue}
class="rounded border border-gray-700 bg-gray-800 px-2 py-1
text-sm text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500" />
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">Etiqueta (opcional)</span>
<input type="text" bind:value={editingLabel}
placeholder="igual al valor si vacío"
class="rounded border border-gray-700 bg-gray-800 px-2 py-1
text-sm text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500
placeholder:text-gray-700" />
</div>
</div>
{#if cat.optCategories.length > 0}
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">Categoría</span>
<select bind:value={editingCatId}
class="rounded border border-gray-700 bg-gray-800 px-2 py-1
text-sm text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500">
<option value="">— sin categoría —</option>
{#each cat.optCategories as oc (oc.id)}
<option value={oc.id}>
{typeof oc.name === 'string' ? oc.name : (oc.name as any)?.['es'] ?? oc.id}
</option>
{/each}
</select>
</div>
{/if}
<div class="flex gap-2 justify-end">
<button onclick={cancelEdit}
class="{btnSm} bg-gray-800 text-gray-400 hover:text-gray-200">
Cancelar
</button>
<button onclick={saveEdit}
class="{btnSm} bg-blue-600 text-white hover:bg-blue-500">
Guardar
</button>
</div>
</div>
{:else}
<!-- Fila normal -->
<div class="flex items-center gap-2 rounded border border-gray-800
bg-gray-900 px-3 py-2 group hover:border-gray-700 transition-colors">
<span class="font-mono text-sm text-gray-100 min-w-[6rem]">
{String(opt.value.value)}
</span>
{#if opt.label}
<span class="text-sm text-gray-500">
→ {typeof opt.label === 'string' ? opt.label : (opt.label as any)?.['es'] ?? ''}
</span>
{/if}
{#if opt.categoryId}
<span class="ml-auto text-xs px-1.5 py-0.5 rounded bg-gray-800
text-gray-500 border border-gray-700">
{cat.optCategories.find(c => c.id === opt.categoryId)?.name ?? opt.categoryId}
</span>
{:else}
<span class="ml-auto"></span>
{/if}
<button onclick={() => startEdit(opt.id)}
class="{btnSm} text-gray-600 hover:text-blue-400
opacity-0 group-hover:opacity-100 transition-opacity">
✎
</button>
<button onclick={() => editor.removeOpt(opt.id)}
class="{btnSm} text-gray-600 hover:text-red-400
opacity-0 group-hover:opacity-100 transition-opacity">
✕
</button>
</div>
{/if}
{/each}
</div>
{/if}
</div>
</div>

@ -0,0 +1,317 @@
<script lang="ts">
import type { CatalogEditorStore } from '../stores/catalog-editor.svelte';
import type { RuleSet, Rule,
RuleAction, AttState,
RuleActionAttribute,
RuleActionValidation,
RuleActionDomain,
RuleActionValue, Obj, Sec, Opt } from '@/vcen/lib';
import { createConditionBuilderStore } from '../stores/condition-builder.svelte';
import ConditionBuilder from './ConditionBuilder.svelte';
interface Props {
ruleSet: RuleSet;
container: Obj | Sec;
catOpts: Opt[];
editor: CatalogEditorStore;
}
let { ruleSet, container, catOpts, editor }: Props = $props();
// ── Stores de condición por regla (on-demand, estables entre re-renders) ─
const cbStores = new Map<string, ReturnType<typeof createConditionBuilderStore>>();
function getCbStore(rule: Rule) {
if (!cbStores.has(rule.id)) {
cbStores.set(rule.id, createConditionBuilderStore(container, rule.condition, catOpts));
}
return cbStores.get(rule.id)!;
}
function syncCondition(rule: Rule) {
const store = getCbStore(rule);
if (store.isValid) {
editor.updateRule(ruleSet, rule.id, { condition: store.compiled as any });
}
}
// ── Tipos de acción ───────────────────────────────────────────────────────
type ActionKind = RuleAction['type'];
const ACTION_KINDS: { kind: ActionKind; label: string }[] = [
{ kind: 'attribute', label: 'Estado del atributo' },
{ kind: 'validation', label: 'Validación' },
{ kind: 'domain', label: 'Dominio' },
{ kind: 'value', label: 'Valor' },
];
const ATT_STATES: { state: AttState; label: string }[] = [
{ state: 'available', label: 'Disponible' },
{ state: 'required', label: 'Requerido' },
{ state: 'hidden', label: 'Oculto' },
{ state: 'forbidden', label: 'Prohibido' },
];
function defaultAction(kind: ActionKind): RuleAction {
switch (kind) {
case 'attribute': return { type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' };
case 'validation': return { type: 'validation', level: 'error', message: '' };
case 'domain': return { type: 'domain', target: [], state: 'allowed' };
case 'value': return { type: 'value', target: [], value: { domain: { kind: 'string', mode: 'input' }, value: '' } };
}
}
// ── Labels editables por acción (local, no se persisten en el Cat) ────────
// Se guardan en (action as any).label si el usuario escribe algo
function getActionLabel(action: RuleAction, idx: number): string {
return (action as any).label ?? '';
}
function setActionLabel(ruleId: string, idx: number, label: string, rule: Rule) {
const action = rule.actions[idx];
if (!action) return;
editor.updateAction(ruleSet, ruleId, idx, { ...action, label } as any);
}
// ── Reglas colapsadas ────────────────────────────────────────────────────
let collapsedRules = $state<Set<string>>(new Set());
function toggleCollapse(ruleId: string) {
const next = new Set(collapsedRules);
next.has(ruleId) ? next.delete(ruleId) : next.add(ruleId);
collapsedRules = next;
}
</script>
<div class="flex flex-col gap-2">
{#if ruleSet.rules.length === 0}
<p class="text-xs text-gray-600 italic px-1">Sin reglas</p>
{/if}
{#each ruleSet.rules as rule (rule.id)}
{@const cbStore = getCbStore(rule)}
{@const collapsed = collapsedRules.has(rule.id)}
<div class="rounded border border-gray-700 overflow-hidden">
<!-- ── Header de la regla ──────────────────────────────────────── -->
<div class="flex items-center gap-2 px-3 py-2 bg-gray-900 border-b border-gray-700">
<button
onclick={() => toggleCollapse(rule.id)}
class="text-gray-600 hover:text-gray-300 transition-colors text-xs w-4 shrink-0"
>{collapsed ? '▸' : '▾'}</button>
<span class="text-[11px] text-gray-600 shrink-0">P</span>
<input
type="number"
value={rule.priority}
oninput={e => 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"
/>
<span class="flex-1 text-xs text-gray-500">
{rule.actions.length} acción{rule.actions.length !== 1 ? 'es' : ''}
{#if !cbStore.isValid}
<span class="ml-1 text-amber-600">· condición incompleta</span>
{/if}
</span>
<button
onclick={() => editor.removeRule(ruleSet, rule.id)}
class="text-[11px] text-gray-600 hover:text-red-400 transition-colors shrink-0"
>✕</button>
</div>
{#if !collapsed}
<div class="flex flex-col gap-3 p-3 bg-gray-900/50">
<!-- Condición -->
<div class="flex flex-col gap-1.5">
<span class="section-title-inline">Condición</span>
<div class="rounded border border-gray-700 p-2 bg-gray-900">
<ConditionBuilder
store={cbStore}
onchange={() => syncCondition(rule)}
/>
</div>
</div>
<!-- Acciones -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="section-title-inline">Acciones</span>
<select
onchange={e => {
const kind = e.currentTarget.value as ActionKind;
if (!kind) return;
editor.addAction(ruleSet, rule.id, defaultAction(kind));
e.currentTarget.value = '';
}}
class="field-input"
style="width:auto;font-size:0.75rem;padding:0.25rem 1.75rem 0.25rem 0.5rem"
>
<option value="">+ Añadir acción...</option>
{#each ACTION_KINDS as { kind, label }}
<option value={kind}>{label}</option>
{/each}
</select>
</div>
{#if rule.actions.length === 0}
<p class="text-xs text-gray-700 italic px-1">Sin acciones</p>
{/if}
{#each rule.actions as action, idx (idx)}
<div class="flex flex-col gap-2 rounded border border-gray-800 bg-gray-900 p-2.5">
<!-- Header acción: tipo + nombre editable + eliminar -->
<div class="flex items-center gap-2">
<span class="text-[11px] px-1.5 py-0.5 rounded bg-indigo-900/40
text-indigo-400 shrink-0 font-medium">
{ACTION_KINDS.find(k => k.kind === action.type)?.label ?? action.type}
</span>
<input
type="text"
value={getActionLabel(action, idx)}
oninput={e => 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"
/>
<button
onclick={() => editor.removeAction(ruleSet, rule.id, idx)}
class="text-[11px] text-gray-600 hover:text-red-400 transition-colors shrink-0"
>✕</button>
</div>
<!-- Campos según tipo -->
{#if action.type === 'attribute'}
{@const a = action as RuleActionAttribute}
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<span class="text-[11px] text-gray-600">Si cumple</span>
<select
value={a.ifTrue}
onchange={e => editor.updateAction(ruleSet, rule.id, idx,
{ ...a, ifTrue: e.currentTarget.value as AttState })}
class="field-input">
{#each ATT_STATES as { state, label }}
<option value={state}>{label}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1">
<span class="text-[11px] text-gray-600">Si no cumple</span>
<select
value={a.ifFalse ?? 'available'}
onchange={e => editor.updateAction(ruleSet, rule.id, idx,
{ ...a, ifFalse: e.currentTarget.value as AttState })}
class="field-input">
{#each ATT_STATES as { state, label }}
<option value={state}>{label}</option>
{/each}
</select>
</div>
</div>
<div class="flex flex-col gap-1">
<span class="text-[11px] text-gray-600">Target (vacío = self)</span>
<input type="text"
value={(a.target ?? []).join(', ')}
oninput={e => 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" />
</div>
{:else if action.type === 'validation'}
{@const a = action as RuleActionValidation}
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<span class="text-[11px] text-gray-600">Nivel</span>
<select value={a.level}
onchange={e => editor.updateAction(ruleSet, rule.id, idx,
{ ...a, level: e.currentTarget.value as any })}
class="field-input">
<option value="error">Error</option>
<option value="warning">Aviso</option>
</select>
</div>
<div class="flex flex-col gap-1">
<span class="text-[11px] text-gray-600">Mensaje</span>
<input type="text" value={a.message ?? ''}
oninput={e => editor.updateAction(ruleSet, rule.id, idx,
{ ...a, message: e.currentTarget.value })}
placeholder="Campo obligatorio"
class="field-input" />
</div>
</div>
{:else if action.type === 'domain'}
{@const a = action as RuleActionDomain}
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<span class="text-[11px] text-gray-600">Estado</span>
<select value={a.state}
onchange={e => editor.updateAction(ruleSet, rule.id, idx,
{ ...a, state: e.currentTarget.value as any })}
class="field-input">
<option value="allowed">Permitido</option>
<option value="forbidden">Prohibido</option>
</select>
</div>
<div class="flex flex-col gap-1">
<span class="text-[11px] text-gray-600">Target</span>
<input type="text"
value={(a.target ?? []).join(', ')}
oninput={e => editor.updateAction(ruleSet, rule.id, idx,
{ ...a, target: e.currentTarget.value
.split(',').map((s:string) => s.trim()).filter(Boolean) as any })}
class="field-input" />
</div>
</div>
{:else if action.type === 'value'}
{@const a = action as RuleActionValue}
<div class="flex flex-col gap-1">
<span class="text-[11px] text-gray-600">Valor (JSON)</span>
<input type="text"
value={JSON.stringify((a.value as any)?.value ?? '')}
oninput={e => {
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" />
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
{/each}
<!-- Añadir regla -->
<button
onclick={() => editor.addRule(ruleSet)}
class="rounded border border-dashed border-gray-700 px-3 py-2 text-sm
text-gray-600 hover:text-indigo-400 hover:border-indigo-700 transition-colors w-full mt-1"
>+ Regla</button>
</div>

@ -0,0 +1,118 @@
<script lang="ts">
import type { CatalogEditorStore } from './catalog-editor.svelte';
import RuleSetEditor from './RuleSetEditor.svelte';
interface Props { editor: CatalogEditorStore; }
let { editor }: Props = $props();
const sel = $derived(editor.selection);
const sec = $derived(
sel.kind === 'sec' ? editor.findSec(sel.objId, sel.secId) ?? null : null
);
const obj = $derived(
sel.kind === 'sec' ? editor.findObj(sel.objId) ?? null : null
);
let nameInput = $state('');
$effect(() => { if (sec) nameInput = sec.name; });
function onNameBlur() {
if (!sec || sel.kind !== 'sec' || nameInput === sec.name) return;
editor.renameSec(sel.objId, sel.secId, nameInput);
}
function ensureRuleSet() {
if (!sec || sel.kind !== 'sec' || sec.rules) return;
editor.setSecRuleSet(sel.objId, sel.secId, { rules: [] });
}
function removeRuleSet() {
if (sel.kind !== 'sec') return;
editor.setSecRuleSet(sel.objId, sel.secId, undefined);
}
const inputCls = `w-full rounded border border-gray-700 bg-gray-900 px-3 py-2
text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500`;
const labelCls = `text-xs font-semibold uppercase tracking-wider text-gray-500`;
</script>
{#if !sec || !obj || sel.kind !== 'sec'}
<div class="flex items-center justify-center h-40 text-sm text-gray-600">
Selecciona una sección
</div>
{:else}
<div class="flex flex-col gap-6 p-5">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex flex-col gap-0.5">
<span class="text-xs text-gray-600">{obj.name}</span>
<h2 class="text-base font-semibold text-gray-100">{sec.name}</h2>
</div>
<button
onclick={() => editor.removeSec(sel.objId, sel.secId)}
class="text-xs text-red-500 hover:text-red-400 transition-colors"
>Eliminar sección</button>
</div>
<!-- Nombre -->
<div class="flex flex-col gap-1.5">
<label class={labelCls}>Nombre</label>
<input type="text" bind:value={nameInput} onblur={onNameBlur} class={inputCls} />
</div>
<!-- Resumen -->
<div class="flex flex-col gap-1.5">
<span class={labelCls}>Estructura</span>
<div class="rounded border border-gray-800 bg-gray-900 p-3 text-sm">
<span class="text-gray-400">
<span class="text-gray-100 font-medium">{sec.atts.length}</span> atributos
</span>
</div>
</div>
<!-- Orden -->
<div class="flex flex-col gap-1.5">
<span class={labelCls}>Posición en "{obj.name}"</span>
<div class="flex gap-2">
<button
onclick={() => editor.moveSec(sel.objId, sel.secId, 'up')}
class="rounded border border-gray-700 px-3 py-1.5 text-sm text-gray-400
hover:text-gray-100 hover:border-gray-500 transition-colors"
>↑ Subir</button>
<button
onclick={() => editor.moveSec(sel.objId, sel.secId, 'down')}
class="rounded border border-gray-700 px-3 py-1.5 text-sm text-gray-400
hover:text-gray-100 hover:border-gray-500 transition-colors"
>↓ Bajar</button>
</div>
</div>
<!-- RuleSet -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class={labelCls}>Reglas</span>
{#if sec.rules}
<button onclick={removeRuleSet}
class="text-xs text-red-500 hover:text-red-400 transition-colors">
✕ Eliminar reglas
</button>
{:else}
<button onclick={ensureRuleSet}
class="text-xs text-blue-400 hover:text-blue-300 transition-colors">
+ Añadir reglas
</button>
{/if}
</div>
{#if sec.rules}
<RuleSetEditor
ruleSet={sec.rules}
container={sec}
catOpts={editor.cat.opts}
{editor}
/>
{/if}
</div>
</div>
{/if}

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { DemoStore, DomainKind } from './demo.store.svelte'; import type { DemoStore, DomainKind } from './demo.store.svelte.js';
import type { Att } from '@/vcen/lib/types'; import type {Att, AttDomain} from '@/vcen/lib/types';
import { createConditionBuilderStore } from '../../stores/ConditionEditor.svelte'; import { createConditionBuilderStore } from '../stores/condition-builder.svelte.ts';
import ConditionBuilder from '../ConditionBuilder.svelte'; import ConditionBuilder from '../comps/ConditionBuilder.svelte';
interface Props { store: DemoStore; } interface Props { store: DemoStore; }
let { store }: Props = $props(); let { store }: Props = $props();
@ -74,6 +74,13 @@
if (kind === 'boolean') coerced = defaultVal === 'true' || defaultVal === true; if (kind === 'boolean') coerced = defaultVal === 'true' || defaultVal === true;
store.updateAttDefaultValue(att.id, coerced); store.updateAttDefaultValue(att.id, coerced);
} }
function asAttDomain (domain: unknown) : AttDomain {
return domain as AttDomain;
}
</script> </script>
{#if !att} {#if !att}
@ -123,7 +130,7 @@
</button> </button>
</div> </div>
<div class="flex flex-wrap gap-1 mt-1"> <div class="flex flex-wrap gap-1 mt-1">
{#each (att.value.domain as any).options ?? [] as opt} {#each (att.value.domain as AttDomainOptions).options ?? [] as opt}
<span class="px-2 py-0.5 rounded-full bg-gray-800 text-xs text-gray-300 <span class="px-2 py-0.5 rounded-full bg-gray-800 text-xs text-gray-300
border border-gray-700">{opt}</span> border border-gray-700">{opt}</span>
{/each} {/each}

@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { createDemoStore } from './demo.store.svelte'; import { createDemoStore } from './demo.store.svelte.js';
import AttEditor from './AttEditor.svelte'; import AttEditor from './AttEditor.svelte';
import RuntimePanel from './RuntimePanel.svelte'; import RuntimePanel from './RuntimePanel.svelte';
import GraphPanel from './GraphPanel.svelte'; import GraphPanel from './GraphPanel.svelte';
import type { DomainKind } from './demo.store.svelte'; import type { DomainKind } from './demo.store.svelte.js';
const store = createDemoStore(); const store = createDemoStore();

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { createConditionBuilderStore } from '../../stores/ConditionEditor.svelte.js'; import { createConditionBuilderStore } from '../stores/condition-builder.svelte.ts';
import ConditionBuilder from '../ConditionBuilder.svelte'; import ConditionBuilder from '../comps/ConditionBuilder.svelte';
import type { Obj } from '@/vcen/lib/types'; import type { Obj } from '@/vcen/lib/types';
import type { JsonLogicValue } from '@/jslg'; import type { JsonLogicValue } from '@/jslg';

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { DemoStore } from './demo.store.svelte'; import type { DemoStore } from './demo.store.svelte.js';
import { getTransitiveAffected, getDependencies } from '@/vcen/lib/graph'; import { getTransitiveAffected, getDependencies } from '@/vcen/lib/graph.ts';
interface Props { store: DemoStore; } interface Props { store: DemoStore; }

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { DemoStore } from './demo.store.svelte'; import type { DemoStore } from './demo.store.svelte.js';
interface Props { store: DemoStore; } interface Props { store: DemoStore; }
let { store }: Props = $props(); let { store }: Props = $props();

@ -1,9 +1,9 @@
import { createSession } from '@/vcen/lib/session'; import { createSession } from '@/vcen/lib/session.ts';
import { buildDependencyGraph } from '@/vcen/lib/graph'; import { buildDependencyGraph } from '@/vcen/lib/graph.ts';
import type { Cat, Obj, Att, Sec } from '@/vcen/lib/types'; import type { Cat, Obj, Att, Sec } from '@/vcen/lib/types';
import type { Rule } from '@/vcen/lib/types'; import type { Rule } from '@/vcen/lib/types';
import type { DependencyGraph } from '@/vcen/lib/types'; import type { DependencyGraph } from '@/vcen/lib/types';
import type { Session } from '@/vcen/lib/session'; import type { Session } from '@/vcen/lib/session.ts';
import {evaluateCat} from "@/vcen/lib/engine.ts"; import {evaluateCat} from "@/vcen/lib/engine.ts";
@ -192,6 +192,8 @@ export type DemoStore = ReturnType<typeof createDemoStore>;
function makeFreshCat(): Cat { function makeFreshCat(): Cat {
return { return {
opts : [],
optCategories: [],
id: 'ct_demo' as Cat['id'], id: 'ct_demo' as Cat['id'],
name: 'Catálogo demo', name: 'Catálogo demo',
atts: [], atts: [],

@ -1,5 +1,5 @@
import type { JsonLogicValue } from '@/jslg'; import type { JsonLogicValue } from '@/jslg';
import type { Att, Sec, Obj , AttDomain } from '@/vcen/lib/types'; import type {Att, Sec, Obj, AttDomain, OptID, Opt, AttOptionDomain} from '@/vcen/lib/types';
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// CONDITION TREE — representación interna del editor // CONDITION TREE — representación interna del editor
@ -41,24 +41,45 @@ export type Operand = VarOperand | LiteralOperand;
// SCOPE — atts visibles desde la regla // SCOPE — atts visibles desde la regla
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/**
* Opción resuelta para el editor — combina la definición global (`Opt`)
* con la sobreescritura local (`AttOpt`) para lo que el editor necesita mostrar.
*/
export interface ResolvedOpt {
optId: OptID;
value: Opt['value']; // valor que se usará en la condición JsonLogic
label?: Opt['label']; // label local si existe, sino el global
}
export interface ScopeEntry { export interface ScopeEntry {
path: string; // relativo al contenedor: "at_color", "sc_motor/at_fuel" path: string; // relativo al contenedor: "at_color", "sc_motor/at_fuel"
att: Att; att: Att;
domain: AttDomain; domain: AttDomain;
resolvedOpts?: ResolvedOpt[]; // solo presente si domain.kind === 'option' && mode === 'list'
} }
/** /**
* Construye el scope de variables disponibles para una regla. * Construye el scope de variables disponibles para una regla.
* El scope incluye todos los atts del contenedor y sus secciones hijas. * El scope incluye todos los atts del contenedor y sus secciones hijas.
* *
* Para atts de dominio `option/list`, resuelve las opciones disponibles
* cruzando las `AttOpt` del dominio con los `Opt` globales del catálogo,
* de forma que el editor puede ofrecer los valores válidos como literales.
*
* @param container - Obj o Sec que contiene la regla * @param container - Obj o Sec que contiene la regla
* @param catOpts - Opts globales del catálogo (Cat.opts)
*/ */
export function buildScope(container: Obj | Sec): ScopeEntry[] { export function buildScope(container: Obj | Sec, catOpts: Opt[] = []): ScopeEntry[] {
const entries: ScopeEntry[] = []; const entries: ScopeEntry[] = [];
const atts = 'atts' in container ? container.atts : []; const atts = 'atts' in container ? container.atts : [];
for (const att of atts) { for (const att of atts) {
entries.push({ path: att.id, att, domain: att.value.domain }); entries.push({
path: att.id,
att,
domain: att.value.domain,
resolvedOpts: resolveOpts(att.value.domain, catOpts),
});
} }
if ('secs' in container) { if ('secs' in container) {
@ -68,6 +89,7 @@ export function buildScope(container: Obj | Sec): ScopeEntry[] {
path: `${sec.id}/${att.id}`, path: `${sec.id}/${att.id}`,
att, att,
domain: att.value.domain, domain: att.value.domain,
resolvedOpts: resolveOpts(att.value.domain, catOpts),
}); });
} }
} }
@ -76,6 +98,34 @@ export function buildScope(container: Obj | Sec): ScopeEntry[] {
return entries; return entries;
} }
/**
* Resuelve las opciones para un dominio `option/list`.
* Devuelve `undefined` para cualquier otro dominio.
*
* La resolución combina:
* 1. El `label` local del `AttOpt` si existe, sino el global del `Opt`
* 2. El `value` siempre viene del `Opt` global (no se puede sobreescribir)
* 3. Si un `AttOpt` referencia un `Opt` que no existe en `catOpts` → se omite
*/
function resolveOpts(domain: AttDomain, catOpts: Opt[]): ResolvedOpt[] | undefined {
if (domain.kind !== 'option' || domain.mode !== 'list') return undefined;
const results: ResolvedOpt[] = [];
for (const attOpt of (domain as AttOptionDomain & { mode: 'list' }).list) {
const globalOpt = catOpts.find(o => o.id === attOpt.optId);
if (!globalOpt) continue; // referencia rota — se omite silenciosamente
results.push({
optId: attOpt.optId,
value: globalOpt.value,
label: attOpt.label ?? globalOpt.label,
});
}
return results.length > 0 ? results : undefined;
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// OPERATORS — válidos según el dominio // OPERATORS — válidos según el dominio
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────

@ -0,0 +1,520 @@
import type { Cat, Obj, Sec , Att, AttDomain, AttValue,
Opt, OptCategory, AttOpt,
OptID, OptCatID , RuleSet, Rule, RuleAction,
RuleID, AttState, RelativePath,
DomainValueRef, DomainValueState,
RuleLevel , CatID, ObjID, SecID, AttID } from '@/vcen/lib';
// ─────────────────────────────────────────────────────────────────────────────
// SELECTION — nodo activo en el árbol
// ─────────────────────────────────────────────────────────────────────────────
export type SelectionKind = 'cat' | 'obj' | 'sec' | 'att' | 'opts';
export type Selection =
| { kind: 'cat' }
| { kind: 'opts' } // panel Opts/OptCategories
| { kind: 'obj'; objId: ObjID }
| { kind: 'sec'; objId: ObjID; secId: SecID }
| { kind: 'att'; objId: ObjID; secId?: SecID; attId: AttID };
// ─────────────────────────────────────────────────────────────────────────────
// ID GENERATORS
// ─────────────────────────────────────────────────────────────────────────────
let _seq = Date.now();
function nextId<T extends string>(prefix: string): T {
return `${prefix}:${(++_seq).toString(36)}` as T;
}
// ─────────────────────────────────────────────────────────────────────────────
// DEFAULT FACTORIES
// ─────────────────────────────────────────────────────────────────────────────
function defaultAttValue(): AttValue {
return { domain: { kind: 'string', mode: 'input' }, value: '' };
}
function defaultAtt(name = 'Nuevo atributo'): Att {
return { id: nextId<AttID>('at'), name, value: defaultAttValue() };
}
function defaultSec(name = 'Nueva sección'): Sec {
return { id: nextId<SecID>('sc'), name, atts: [] };
}
function defaultObj(name = 'Nuevo objeto'): Obj {
return { id: nextId<ObjID>('ob'), name, atts: [], secs: [] };
}
function defaultOpt(value = ''): Opt {
return {
id: nextId<OptID>('op'),
value: { kind: 'string', value },
};
}
function defaultOptCategory(name = 'Nueva categoría'): OptCategory {
return { id: nextId<OptCatID>('oc'), name };
}
function defaultRule(): Rule {
return {
id: nextId<RuleID>('rl'),
priority: 10,
condition: true,
actions: [],
};
}
// ─────────────────────────────────────────────────────────────────────────────
// STORE
// ─────────────────────────────────────────────────────────────────────────────
/**
* Store Svelte 5 para el editor de catálogo.
*
* Gestiona el Cat en modo borrador con mutaciones granulares.
* La UI lee `cat` directamente (reactivo) y llama a las mutaciones
* para cada operación de edición.
*
* ## Uso
*
* ```svelte
* <script>
* import { createCatalogEditorStore } from './catalog-editor.svelte';
* const editor = createCatalogEditorStore(cat);
* </script>
*
* <CatalogTree {editor} />
* ```
*/
export function createCatalogEditorStore(initial: Cat) {
let cat = $state<Cat>(structuredClone(initial));
let selection = $state<Selection>({ 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<Pick<Opt, 'value' | 'label' | 'categoryId' | 'rules'>>) {
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<Pick<Rule, 'condition' | 'priority'>>) {
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<typeof createCatalogEditorStore>;
// ─────────────────────────────────────────────────────────────────────────────
// 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 '';
}
}

@ -1,13 +1,13 @@
import type { JsonLogicValue } from '@/jslg'; import type { JsonLogicValue } from '@/jslg';
import type { Obj, Sec } from '@/vcen/lib/types'; import type {Obj, Opt, Sec} from '@/vcen/lib/types';
import type { import type {
ScopeEntry, ScopeEntry,
ConditionNode, ConditionNode,
ConditionError, ConditionError,
CompareOp, CompareOp,
LogicOp, LogicOp,
Operand Operand, ResolvedOpt
} from '../lib/condition_editor'; } from '../lib/condition-editor.ts';
import { import {
buildScope, buildScope,
compileCondition, compileCondition,
@ -20,7 +20,7 @@ import {
removeNode, removeNode,
addChild, addChild,
getValidOperators, getValidOperators,
} from '../lib/condition_editor'; } from '../lib/condition-editor.ts';
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// STORE // STORE
@ -53,6 +53,7 @@ import {
export function createConditionBuilderStore( export function createConditionBuilderStore(
container: Obj | Sec, container: Obj | Sec,
initial?: JsonLogicValue, initial?: JsonLogicValue,
catOpts: Opt[] = [],
) { ) {
// ── Estado reactivo ─────────────────────────────────────────────────────── // ── Estado reactivo ───────────────────────────────────────────────────────
@ -62,7 +63,7 @@ export function createConditionBuilderStore(
: makeLogicNode('and', []) : makeLogicNode('and', [])
); );
const scope = $derived<ScopeEntry[]>(buildScope(container)); const scope = $derived<ScopeEntry[]>(buildScope(container, catOpts));
const errors = $derived<ConditionError[]>( const errors = $derived<ConditionError[]>(
validateCondition(root, scope) validateCondition(root, scope)
@ -138,6 +139,19 @@ export function createConditionBuilderStore(
return getValidOperators(entry.domain); 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). * Carga una condición existente en el editor (p.ej. al abrir una regla).
*/ */
@ -171,6 +185,7 @@ export function createConditionBuilderStore(
setRight, setRight,
setLogicOp, setLogicOp,
validOperatorsFor, validOperatorsFor,
optsFor,
load, load,
reset, reset,
}; };

@ -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
* <script>
* import { createSessionStore } from './session.svelte';
*
* const store = createSessionStore(cat);
* </script>
*
* <input
* value={store.getValue(['ct:cars', 'ob:bmw', 'at:color'])}
* oninput={e => store.setValue(['ct:cars', 'ob:bmw', 'at:color'], e.target.value)}
* />
*
* {#if !store.isValid}
* <p>Hay {store.errors.length} errores</p>
* {/if}
* ```
*
* ## Uso con estado efectivo
*
* ```svelte
* {@const es = store.effectiveState(['ct:cars', 'ob:bmw', 'at:color'])}
*
* {#if es?.state === 'hidden'}
* <!-- no renderizar -->
* {:else if es?.state === 'required'}
* <RequiredField />
* {/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<EvaluationResult>(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<typeof createSessionStore>;

@ -13,9 +13,10 @@ import {
replaceNode, replaceNode,
removeNode, removeNode,
addChild, addChild,
} from '../lib/condition_editor'; } from '../lib/condition-editor.ts';
import type { Att, Sec, Obj } from '@/vcen/lib/types'; import type {Att, Sec, Obj, Opt} from '@/vcen/lib/types';
import type { ScopeEntry } from '../lib/condition_editor'; import type { ScopeEntry } from '../lib/condition-editor.ts';
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// FIXTURES // FIXTURES
@ -274,18 +275,18 @@ describe('parseCondition', () => {
}); });
it('compile(parse(x)) === x para condición simple', () => { 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); const node = parseCondition(original);
expect(compileCondition(node)).toEqual(original); expect(compileCondition(node)).toEqual(original);
}); });
it('compile(parse(x)) === x para condición compleja', () => { it('compile(parse(x)) === x para condición compleja', () => {
const original = asJsonCondition({ const original = {
and: [ and: [
{ '==': [{ var: 'at_color' }, 'rojo'] }, { '==': [{ var: 'at_color' }, 'rojo'] },
{ '!=': [{ var: 'at_acabado' }, 'lujo'] }, { '!=': [{ var: 'at_acabado' }, 'lujo'] },
], ],
}); } as unknown as JsonLogicValue;
expect(compileCondition(parseCondition(original))).toEqual(original); expect(compileCondition(parseCondition(original))).toEqual(original);
}); });
}); });
@ -424,3 +425,125 @@ describe('mutación del árbol', () => {
expect(root.children).toHaveLength(0); 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();
});
});

@ -8,11 +8,11 @@ import type {
AttEffectiveState, AttEffectiveState,
EffectiveState, EffectiveState,
EvaluationResult, EvaluationResult,
RuleID, OptID, AttOptionDomain, Opt, AttValueOption, AttState, RuleID, OptID, AttOptionDomain, Opt, AttValueOption, AttState, ConfigState,
} from './types'; } from './types';
import { ATT_STATE_PRECEDENCE } from './types'; import { ATT_STATE_PRECEDENCE } from './types';
import {validateAttValue} from "@/vcen/lib/validation.ts"; 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"; import {JsonLogicEngine} from "@/jslg";
@ -30,20 +30,10 @@ const MAX_ITERATIONS = 20;
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/** Resuelve la instancia de JsonLogicEngine a usar */ /** Resuelve la instancia de JsonLogicEngine a usar */
export function resolveEngine(engine: JsonLogicEngine | null): JsonLogicEngine { function resolveEngine(engine: JsonLogicEngine | null): JsonLogicEngine {
return engine ?? new 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<string, unknown>;
/** /**
* Contexto de evaluación de una regla. * Contexto de evaluación de una regla.
@ -52,6 +42,7 @@ export type ConfigState = Record<string, unknown>;
*/ */
interface EvalContext { interface EvalContext {
state: ConfigState; state: ConfigState;
nestedState: Record<string, unknown>; // árbol anidado pre-calculado (inmutable por iteración)
prefix: string; // ej: 'ct:vehicles/ob:bmw430' para reglas de Obj 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 * 'at:fuel': 'gasolina' // relativo a la sec
* } * }
*/ */
function buildJsonLogicData(ctx: EvalContext): Record<string, unknown> { /**
const data: Record<string, unknown> = {}; * 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<string, unknown> {
const root: Record<string, unknown> = {};
function setNested(obj: Record<string, unknown>, parts: string[], value: unknown) { for (const [key, value] of Object.entries(state)) {
let current = obj; const parts = parseAttPath(key).filter(Boolean);
let current = root;
for (let i = 0; i < parts.length - 1; i++) { for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]; const part = parts[i];
if (current[part] == null || typeof current[part] !== 'object') { if (current[part] == null || typeof current[part] !== 'object') {
@ -125,26 +126,27 @@ function buildJsonLogicData(ctx: EvalContext): Record<string, unknown> {
current[parts[parts.length - 1]] = value; current[parts[parts.length - 1]] = value;
} }
const prefixParts = keyToAttPath(ctx.prefix).filter(Boolean); return root;
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);
// 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<string, unknown> {
const prefixParts = parseAttPath(ctx.prefix).filter(Boolean);
// Navegar hasta el scope local dentro del árbol pre-calculado
let localScope: Record<string, unknown> = ctx.nestedState;
for (const part of prefixParts) {
const next = localScope[part];
if (next != null && typeof next === 'object') {
localScope = next as Record<string, unknown>;
} 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<string, { priority: number }>(); const valuePriorities = new Map<string, { priority: number }>();
if (att.rules) { 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); applyRuleSet(att.rules, ctx, effectiveState, attPath, prefix, valuePriorities, resolvedEngine);
} }
@ -451,16 +454,17 @@ export function evaluateSec(
const secPath = joinPath(prefix, sec.id); const secPath = joinPath(prefix, sec.id);
const effectiveState = new Map<string, AttEffectiveState>(); const effectiveState = new Map<string, AttEffectiveState>();
const valuePriorities = new Map<string, { priority: number }>(); const valuePriorities = new Map<string, { priority: number }>();
const nestedState = buildNestedState(state);
if (sec.rules) { 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); applyRuleSet(sec.rules, ctx, effectiveState, secPath, secPath, valuePriorities, resolvedEngine);
} }
for (const att of sec.atts) { for (const att of sec.atts) {
const attPath = joinPath(secPath, att.id); const attPath = joinPath(secPath, att.id);
if (att.rules) { 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); applyRuleSet(att.rules, ctx, effectiveState, attPath, secPath, valuePriorities, resolvedEngine);
} }
if (!effectiveState.has(attPath)) { if (!effectiveState.has(attPath)) {
@ -480,6 +484,7 @@ export function evaluateSec(
* @param state - Estado plano del configurador * @param state - Estado plano del configurador
* @param engine * @param engine
* @param opts * @param opts
* @param nestedState
* @returns EffectiveState completo del objeto * @returns EffectiveState completo del objeto
*/ */
export function evaluateObj( export function evaluateObj(
@ -488,20 +493,22 @@ export function evaluateObj(
state: ConfigState, state: ConfigState,
engine: JsonLogicEngine | null = null, engine: JsonLogicEngine | null = null,
opts: Opt[] = [], opts: Opt[] = [],
nestedState?: Record<string, unknown>, // pre-calculado por evaluateCat; se construye si no se pasa
): EffectiveState { ): EffectiveState {
const resolvedEngine = resolveEngine(engine); const resolvedEngine = resolveEngine(engine);
const nested = nestedState ?? buildNestedState(state);
const objPath = joinPath(prefix, obj.id); const objPath = joinPath(prefix, obj.id);
const effectiveState = new Map<string, AttEffectiveState>(); const effectiveState = new Map<string, AttEffectiveState>();
const valuePriorities = new Map<string, { priority: number }>(); const valuePriorities = new Map<string, { priority: number }>();
if (obj.rules) { 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); applyRuleSet(obj.rules, ctx, effectiveState, objPath, objPath, valuePriorities, resolvedEngine);
} }
for (const att of obj.atts) { for (const att of obj.atts) {
const attPath = joinPath(objPath, att.id); const attPath = joinPath(objPath, att.id);
const ctx: EvalContext = { state, prefix: objPath }; const ctx: EvalContext = { state, nestedState: nested, prefix: objPath };
if (att.rules) { if (att.rules) {
applyRuleSet(att.rules, ctx, effectiveState, attPath, objPath, valuePriorities, resolvedEngine); applyRuleSet(att.rules, ctx, effectiveState, attPath, objPath, valuePriorities, resolvedEngine);
} }
@ -524,13 +531,13 @@ export function evaluateObj(
const secPath = joinPath(objPath, sec.id); const secPath = joinPath(objPath, sec.id);
if (sec.rules) { 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); applyRuleSet(sec.rules, ctx, effectiveState, secPath, secPath, valuePriorities, resolvedEngine);
} }
for (const att of sec.atts) { for (const att of sec.atts) {
const attPath = joinPath(secPath, att.id); const attPath = joinPath(secPath, att.id);
const ctx: EvalContext = { state, prefix: secPath }; const ctx: EvalContext = { state, nestedState: nested, prefix: secPath };
if (att.rules) { if (att.rules) {
applyRuleSet(att.rules, ctx, effectiveState, attPath, secPath, valuePriorities, resolvedEngine); 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) { while (iterations < MAX_ITERATIONS) {
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<string, AttEffectiveState>(); const nextEffective = new Map<string, AttEffectiveState>();
for (const obj of cat.objs) { 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) { for (const [k, v] of objResult) {
nextEffective.set(k, v); nextEffective.set(k, v);
} }
@ -690,4 +700,5 @@ function collectAtts(obj: Obj, catId: string): Array<{ path: string; att: Att }>
} }
return result; return result;
} }// engine.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'

@ -17,7 +17,7 @@ import {
// IDS HELPERS // 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 toObjID (objId : string) : ObjID { return `${objIDPrefix}_${objId}` as ObjID; }
export function toSecID (secId : string) : SecID { return `${secIDPrefix}_${secId}` as SecID; } export function toSecID (secId : string) : SecID { return `${secIDPrefix}_${secId}` as SecID; }
export function toAttID (attId : string) : AttID { return `${attIDPrefix}_${attId}` as AttID; } export function toAttID (attId : string) : AttID { return `${attIDPrefix}_${attId}` as AttID; }

@ -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<string, unknown> {
if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) {
return raw as Record<string, unknown>;
}
ctx.error(`expected object, got ${Array.isArray(raw) ? 'array' : typeof raw}`);
return {};
}
function requireStringEnum<T extends string>(
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<string, string> = {};
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<T>(
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<string, unknown>, 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 [];
}

@ -6,7 +6,7 @@ import type {
AttPath, AttPath,
DependencyGraph DependencyGraph
} from './types'; } from './types';
import { serializeAttPath } from './node_util.ts'; import {keyToAttPath, serializeAttPath} from './node_util.ts';
import { buildDependencyGraph } from './graph.ts'; import { buildDependencyGraph } from './graph.ts';
import { evaluateCat } from './engine.ts'; import { evaluateCat } from './engine.ts';
import { evaluateIncremental } from './incremental.ts'; import { evaluateIncremental } from './incremental.ts';
@ -162,7 +162,7 @@ export class Session {
} }
private findAttValue(key: string): unknown { private findAttValue(key: string): unknown {
const parts = key.split('/'); const parts = keyToAttPath(key)
if (parts.length === 2) { if (parts.length === 2) {
return this.cat.atts.find(a => a.id === parts[1])?.value.value; return this.cat.atts.find(a => a.id === parts[1])?.value.value;

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

@ -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<string, unknown>, 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<string, unknown>;
}
current[parts[parts.length - 1]] = value;
}
function buildJsonLogicDataBefore(state: Record<string, unknown>, prefix: string): Record<string, unknown> {
const data: Record<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown>): Record<string, unknown> {
const root: Record<string, unknown> = {};
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<string, unknown>;
}
current[parts[parts.length - 1]] = value;
}
return root;
}
function buildJsonLogicDataAfter(
nestedState: Record<string, unknown>,
prefix: string,
): Record<string, unknown> {
let localScope: Record<string, unknown> = nestedState;
for (const part of prefix.split(NODE_SEPARATOR).filter(Boolean)) {
const next = localScope[part];
localScope = (next != null && typeof next === 'object') ? next as Record<string, unknown> : {};
}
return { ...nestedState, ...localScope };
}
function simulateAfter(cat: Cat): void {
const state: Record<string, unknown> = {};
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;
}
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.