dev 7 months ago
parent d1586701a2
commit dd34cbf9f1

@ -0,0 +1,519 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ConditionBuilder — Demo</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
* { font-family: 'IBM Plex Sans', sans-serif; }
.mono { font-family: 'IBM Plex Mono', monospace; }
body {
background: #0f0f0f;
color: #e8e8e8;
min-height: 100vh;
}
.panel {
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 8px;
}
.node-logic {
background: #1e1e2e;
border: 1px solid #313158;
border-radius: 6px;
padding: 10px;
}
.node-compare {
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 6px;
padding: 8px 10px;
display: flex;
align-items: center;
gap: 8px;
}
.node-compare:hover { border-color: #3a3a3a; }
select, input[type="text"] {
background: #111;
border: 1px solid #2a2a2a;
border-radius: 4px;
color: #e8e8e8;
padding: 5px 8px;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
select:focus, input:focus { border-color: #5b6af5; }
select option { background: #1a1a1a; }
.btn {
border-radius: 4px;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
border: none;
transition: all 0.15s;
}
.btn-add { background: #1e2d4a; color: #5b9cf5; }
.btn-add:hover { background: #243566; }
.btn-group { background: #2a1e4a; color: #9b6af5; }
.btn-group:hover { background: #321e5e; }
.btn-remove { background: #2a1e1e; color: #f56a6a; }
.btn-remove:hover { background: #3d1e1e; }
.op-badge {
background: #252540;
border: 1px solid #35356a;
color: #8888ff;
border-radius: 4px;
padding: 3px 8px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
user-select: none;
}
.children-indent {
padding-left: 16px;
border-left: 2px solid #252540;
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.compiled-output {
background: #111;
border: 1px solid #2a2a2a;
border-radius: 6px;
padding: 12px;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
.tag-domain {
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
font-weight: 500;
}
.tag-string { background: #1a2e1a; color: #5fc95f; border: 1px solid #2a4a2a; }
.tag-number { background: #1a1e2e; color: #5f8fc9; border: 1px solid #2a304a; }
.tag-boolean { background: #2e1a2e; color: #c95fc9; border: 1px solid #4a2a4a; }
.tag-date { background: #2e2a1a; color: #c9a05f; border: 1px solid #4a3a2a; }
.tag-option { background: #1a2a2e; color: #5fc9c9; border: 1px solid #2a3a4a; }
.tautology-true { background: #1a2a1a; border: 1px solid #2a4a2a; color: #5fc95f; border-radius: 6px; padding: 8px 12px; font-size: 13px; }
.tautology-false { background: #2a1a1a; border: 1px solid #4a2a2a; color: #f56a6a; border-radius: 6px; padding: 8px 12px; font-size: 13px; }
.error-msg { color: #f56a6a; font-size: 11px; padding: 2px 4px; }
.scope-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 4px;
background: #141414;
border: 1px solid #222;
font-size: 12px;
}
.valid-badge { background: #1a2a1a; color: #5fc95f; border: 1px solid #2a4a2a; }
.invalid-badge { background: #2a1a1a; color: #f56a6a; border: 1px solid #4a2a2a; }
.status-badge { border-radius: 4px; padding: 3px 10px; font-size: 12px; font-weight: 500; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
.animate-in { animation: fadeIn 0.15s ease; }
</style>
</head>
<body class="p-6">
<div class="max-w-5xl mx-auto flex flex-col gap-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold tracking-tight">ConditionBuilder</h1>
<p class="text-sm text-gray-500 mt-0.5">Editor de condiciones JsonLogic — Demo</p>
</div>
<div id="status-badge" class="status-badge valid-badge">✓ válida</div>
</div>
<div class="grid grid-cols-[1fr_320px] gap-4">
<!-- Editor -->
<div class="panel p-4 flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-300">Condición</span>
<button onclick="resetEditor()" class="btn btn-remove text-xs">↺ Reset</button>
</div>
<div id="tautology-warning"></div>
<div id="condition-tree"></div>
</div>
<!-- Panel derecho -->
<div class="flex flex-col gap-4">
<!-- Scope -->
<div class="panel p-4 flex flex-col gap-2">
<span class="text-sm font-medium text-gray-300">Variables del scope</span>
<div id="scope-list" class="flex flex-col gap-1.5"></div>
</div>
<!-- Output -->
<div class="panel p-4 flex flex-col gap-2">
<span class="text-sm font-medium text-gray-300 mono">JsonLogicValue</span>
<div id="compiled-output" class="compiled-output mono text-gray-400">—</div>
</div>
<!-- Errores -->
<div id="errors-panel" class="panel p-4 flex flex-col gap-2" style="display:none!important">
<span class="text-sm font-medium text-red-400">Errores</span>
<div id="errors-list" class="flex flex-col gap-1"></div>
</div>
</div>
</div>
</div>
<script>
// ─── MOCK DATA ────────────────────────────────────────────────────────────────
const scope = [
{ path: 'at_color', domain: { kind: 'option' }, label: 'Color', values: ['rojo','azul','negro','blanco'] },
{ path: 'at_acabado', domain: { kind: 'option' }, label: 'Acabado', values: ['base','sport','lujo'] },
{ path: 'at_precio', domain: { kind: 'number' }, label: 'Precio', values: [] },
{ path: 'at_activo', domain: { kind: 'boolean' }, label: 'Activo', values: ['true','false'] },
{ path: 'at_entrega', domain: { kind: 'date' }, label: 'Entrega', values: [] },
{ path: 'sc_motor/at_fuel', domain: { kind: 'option' }, label: 'Combustible', values: ['gasolina','diesel','electrico'] },
{ path: 'sc_motor/at_cv', domain: { kind: 'number' }, label: 'CV', values: [] },
];
const OPS_BY_DOMAIN = {
string: ['==', '!='],
number: ['==', '!=', '>', '<', '>=', '<='],
boolean: ['==', '!='],
option: ['==', '!='],
currency:['==', '!=', '>', '<', '>=', '<='],
date: ['==', '!=', 'date-before', 'date-after', 'date-between'],
time: ['==', '!=', 'date-before', 'date-after', 'date-between'],
datetime:['==', '!=', 'date-before', 'date-after', 'date-between'],
};
const OP_LABELS = {
'==': 'igual a', '!=': 'distinto de',
'>': 'mayor que', '<': 'menor que',
'>=': 'mayor o igual que', '<=': 'menor o igual que',
'date-before': 'antes de', 'date-after': 'después de', 'date-between': 'entre',
};
const DOMAIN_TAGS = {
string: 'tag-string', number: 'tag-number', boolean: 'tag-boolean',
date: 'tag-date', option: 'tag-option', currency: 'tag-number',
};
// ─── STATE ────────────────────────────────────────────────────────────────────
let idCounter = 0;
const newId = () => `n${++idCounter}`;
function makeLogic(op = 'and', children = []) {
return { kind: 'logic', id: newId(), op, children };
}
function makeCompare() {
return { kind: 'compare', id: newId(), op: '==', left: { kind: 'var', path: '' }, right: { kind: 'literal', value: '' } };
}
let root = makeLogic('and', []);
// ─── RENDER ───────────────────────────────────────────────────────────────────
function render() {
// Scope list
const scopeList = document.getElementById('scope-list');
scopeList.innerHTML = scope.map(e => `
<div class="scope-item">
<span class="mono text-gray-300">${e.path}</span>
<span class="tag-domain ${DOMAIN_TAGS[e.domain.kind] || 'tag-string'}">${e.domain.kind}</span>
</div>
`).join('');
// Tree
document.getElementById('condition-tree').innerHTML = renderNode(root, 0);
// Compiled
const compiled = compile(root);
const errors = validate(root);
const taut = tautology(root);
document.getElementById('compiled-output').textContent =
JSON.stringify(compiled, null, 2);
// Tautology
const tw = document.getElementById('tautology-warning');
if (taut === 'always-true') {
tw.innerHTML = `<div class="tautology-true animate-in">⚠ Esta condición siempre es <strong>verdadera</strong></div>`;
} else if (taut === 'always-false') {
tw.innerHTML = `<div class="tautology-false animate-in">⚠ Esta condición siempre es <strong>falsa</strong></div>`;
} else {
tw.innerHTML = '';
}
// Status
const badge = document.getElementById('status-badge');
const valid = errors.length === 0;
badge.className = `status-badge ${valid ? 'valid-badge' : 'invalid-badge'}`;
badge.textContent = valid ? '✓ válida' : `✕ ${errors.length} error${errors.length > 1 ? 'es' : ''}`;
// Errors panel
const ep = document.getElementById('errors-panel');
if (errors.length > 0) {
ep.style.display = '';
document.getElementById('errors-list').innerHTML =
errors.map(e => `<p class="error-msg">• ${e.message}</p>`).join('');
} else {
ep.style.display = 'none';
}
}
function renderNode(node, depth) {
if (node.kind === 'logic') return renderLogic(node, depth);
return renderCompare(node);
}
function renderLogic(node, depth) {
const children = node.children.map(c => renderNode(c, depth + 1)).join('');
const removeBtn = depth > 0
? `<button class="btn btn-remove" onclick="removeNode('${node.id}')">✕</button>`
: '';
return `
<div class="node-logic animate-in">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:${node.children.length ? '8px' : '0'}">
<select class="op-badge" style="border:none;cursor:pointer"
onchange="setLogicOp('${node.id}', this.value)">
<option value="and" ${node.op === 'and' ? 'selected' : ''}>AND — todas</option>
<option value="or" ${node.op === 'or' ? 'selected' : ''}>OR — alguna</option>
</select>
<span style="color:#555;font-size:12px">se cumplen</span>
<div style="margin-left:auto;display:flex;gap:6px">
<button class="btn btn-add" onclick="addCompare('${node.id}')">+ Condición</button>
<button class="btn btn-group" onclick="addGroup('${node.id}')">+ Grupo</button>
${removeBtn}
</div>
</div>
${node.children.length === 0
? `<p style="color:#444;font-size:12px;font-style:italic;padding:4px 0">Sin condiciones — añade una arriba</p>`
: `<div class="children-indent">${children}</div>`
}
</div>`;
}
function renderCompare(node) {
const varOps = node.left.path ? (OPS_BY_DOMAIN[getScopeEntry(node.left.path)?.domain.kind] ?? ['==','!=']) : ['==','!='];
const entry = getScopeEntry(node.left.path);
const vals = entry?.values ?? [];
const error = validateNode(node);
const varSelect = `
<select style="flex:1;min-width:0" onchange="setLeft('${node.id}', this.value)">
<option value="" disabled ${!node.left.path ? 'selected' : ''}>— Variable —</option>
${scope.map(e => `<option value="${e.path}" ${node.left.path === e.path ? 'selected' : ''}>${e.path}</option>`).join('')}
</select>`;
const opSelect = `
<select onchange="setOp('${node.id}', this.value)">
${varOps.map(op => `<option value="${op}" ${node.op === op ? 'selected' : ''}>${OP_LABELS[op] ?? op}</option>`).join('')}
</select>`;
const rightInput = node.op === 'date-between'
? `<input type="text" placeholder="desde" value="${node.rightLo ?? ''}" style="width:90px"
oninput="setRightBetween('${node.id}', 'lo', this.value)">
<span style="color:#555;font-size:12px">y</span>
<input type="text" placeholder="hasta" value="${node.rightHi ?? ''}" style="width:90px"
oninput="setRightBetween('${node.id}', 'hi', this.value)">`
: vals.length > 0
? `<select onchange="setRight('${node.id}', this.value)">
<option value="" disabled ${!node.right.value ? 'selected' : ''}>— Valor —</option>
${vals.map(v => `<option value="${v}" ${node.right.value === v ? 'selected' : ''}>${v}</option>`).join('')}
</select>`
: `<input type="text" placeholder="valor" value="${node.right.value ?? ''}" style="flex:1;min-width:80px"
oninput="setRight('${node.id}', this.value)">`;
return `
<div class="flex flex-col gap-1 animate-in">
<div class="node-compare" style="border-color:${error ? '#4a2a2a' : '#2a2a2a'};background:${error ? '#1e1414' : '#1a1a1a'}">
${varSelect}
${opSelect}
${rightInput}
<button class="btn btn-remove" style="padding:4px 6px" onclick="removeNode('${node.id}')">✕</button>
</div>
${error ? `<p class="error-msg" style="padding-left:4px">• ${error}</p>` : ''}
</div>`;
}
// ─── MUTATIONS ────────────────────────────────────────────────────────────────
function addCompare(parentId) {
root = addChild(root, parentId, makeCompare());
render();
}
function addGroup(parentId) {
root = addChild(root, parentId, makeLogic('and', []));
render();
}
function removeNode(id) {
root = remove(root, id) ?? makeLogic('and', []);
render();
}
function setLogicOp(id, op) {
root = updateNode(root, id, n => ({ ...n, op }));
render();
}
function setLeft(id, path) {
root = updateNode(root, id, n => {
const ops = OPS_BY_DOMAIN[getScopeEntry(path)?.domain.kind] ?? ['==','!='];
const op = ops.includes(n.op) ? n.op : ops[0];
return { ...n, op, left: { kind: 'var', path } };
});
render();
}
function setOp(id, op) {
root = updateNode(root, id, n => ({ ...n, op }));
render();
}
function setRight(id, value) {
root = updateNode(root, id, n => ({ ...n, right: { kind: 'literal', value } }));
render();
}
function setRightBetween(id, side, value) {
root = updateNode(root, id, n => ({
...n,
rightLo: side === 'lo' ? value : (n.rightLo ?? ''),
rightHi: side === 'hi' ? value : (n.rightHi ?? ''),
}));
render();
}
function resetEditor() {
root = makeLogic('and', []);
render();
}
// ─── TREE HELPERS ─────────────────────────────────────────────────────────────
function addChild(node, parentId, child) {
if (node.id === parentId && node.kind === 'logic')
return { ...node, children: [...node.children, child] };
if (node.kind === 'logic')
return { ...node, children: node.children.map(c => addChild(c, parentId, child)) };
return node;
}
function remove(node, id) {
if (node.id === id) return null;
if (node.kind === 'logic') {
const children = node.children.map(c => remove(c, id)).filter(Boolean);
if (children.length === 0 && node !== root) return null;
return { ...node, children };
}
return node;
}
function updateNode(node, id, fn) {
if (node.id === id) return fn(node);
if (node.kind === 'logic')
return { ...node, children: node.children.map(c => updateNode(c, id, fn)) };
return node;
}
function getScopeEntry(path) {
return scope.find(e => e.path === path);
}
// ─── COMPILE ──────────────────────────────────────────────────────────────────
function compile(node) {
if (node.kind === 'logic') {
return { [node.op]: node.children.map(compile) };
}
const left = node.left.path ? { var: node.left.path } : node.left.value;
if (node.op === 'date-between') {
return { 'date-between': [left, node.rightLo ?? '', node.rightHi ?? ''] };
}
return { [node.op]: [left, node.right.value] };
}
// ─── VALIDATE ─────────────────────────────────────────────────────────────────
function validateNode(node) {
if (node.kind !== 'compare') return null;
if (!node.left.path) return 'Selecciona una variable';
const entry = getScopeEntry(node.left.path);
if (!entry) return `Variable "${node.left.path}" no encontrada`;
const ops = OPS_BY_DOMAIN[entry.domain.kind] ?? ['==','!='];
if (!ops.includes(node.op)) return `Operador "${node.op}" no válido para ${entry.domain.kind}`;
if (node.op !== 'date-between' && node.right.value === '') return 'Introduce un valor';
return null;
}
function validate(node) {
if (node.kind === 'logic') {
if (node.children.length === 0) return [{ message: `Grupo ${node.op.toUpperCase()} sin condiciones` }];
return node.children.flatMap(validate);
}
const err = validateNode(node);
return err ? [{ message: err }] : [];
}
// ─── TAUTOLOGY ────────────────────────────────────────────────────────────────
function tautology(node) {
if (node.kind === 'logic') {
const results = node.children.map(tautology);
if (node.op === 'and') {
if (results.some(r => r === 'always-false')) return 'always-false';
if (results.every(r => r === 'always-true')) return 'always-true';
} else {
if (results.some(r => r === 'always-true')) return 'always-true';
if (results.every(r => r === 'always-false')) return 'always-false';
}
return 'unknown';
}
if (node.left.kind !== 'literal') return 'unknown';
if (node.right.kind !== 'literal') return 'unknown';
const a = node.left.value, b = node.right.value;
const result = { '==': a===b, '!=': a!==b, '>': a>b, '<': a<b, '>=': a>=b, '<=': a<=b }[node.op];
if (result === undefined) return 'unknown';
return result ? 'always-true' : 'always-false';
}
// ─── INIT ─────────────────────────────────────────────────────────────────────
render();
</script>
</body>
</html>

@ -8,7 +8,7 @@
<body>
<div id="app"></div>
<script type="module" src="/src/vice/editor/main.ts"></script>
<script type="module" src="/src/vcen/editor/main.ts"></script>
</body>
</html>

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

@ -0,0 +1,15 @@
@import "tailwindcss";
/* Smooth scroll para navegación */
html {
scroll-behavior: smooth;
}
@layer components {
}

@ -0,0 +1,182 @@
<script lang="ts">
import type { ConditionBuilderStore } from '../stores/ConditionEditor.svelte.ts';
import type {CompareNode, Operand} from '../lib/condition_editor.ts';
interface Props {
node: CompareNode;
store: ConditionBuilderStore;
}
let { node, store }: Props = $props();
function opLabel(op: string): string {
const labels: Record<string, string> = {
'==': 'igual a', '!=': 'distinto de',
'>': 'mayor que', '<': 'menor que',
'>=': 'mayor o igual que', '<=': 'menor o igual que',
'date-before': 'antes de', 'date-after': 'después de', 'date-between': 'entre',
};
return labels[op] ?? op;
}
const error = $derived(store.errors.find(e => e.nodeId === node.id));
const scopeEntry = $derived(
node.left.kind === 'var' ? store.scope.find(e => e.path === node.left.path) : undefined
);
const availableOps = $derived(
node.left.kind === 'var'
? store.validOperatorsFor(node.left.path)
: (['==', '!='] as const)
);
// Bindings locales
let selectedVar = $state(node.left.kind === 'var' ? node.left.path : '');
let selectedOp = $state(node.op);
let rightValue = $state(!Array.isArray(node.right) && node.right.kind === 'literal' ? String(node.right.value ?? '') : '');
let rightLo = $state('');
let rightHi = $state('');
$effect(() => {
if (Array.isArray(node.right)) {
const [lo, hi] = node.right;
rightLo = lo.kind === 'literal' ? String(lo.value) : '';
rightHi = hi.kind === 'literal' ? String(hi.value) : '';
}
});
function onVarChange() {
store.setLeft(node.id, { kind: 'var', path: selectedVar });
const ops = store.validOperatorsFor(selectedVar);
if (!ops.includes(selectedOp as any)) {
selectedOp = ops[0];
store.setOperator(node.id, ops[0]);
}
// Reset right al cambiar variable
rightValue = '';
store.setRight(node.id, { kind: 'literal', value: '' });
}
function onOpChange() {
store.setOperator(node.id, selectedOp as any);
}
let inputError = $state('');
function onRightChange() {
const domain = scopeEntry?.domain;
let value: unknown = rightValue;
if (domain?.kind === 'number' || domain?.kind === 'currency') {
if (rightValue === '') { inputError = 'Introduce un n\u00famero'; return; }
const n = Number(rightValue);
if (isNaN(n)) { inputError = 'Debe ser un n\u00famero v\u00e1lido'; return; }
inputError = '';
value = n;
} else if (domain?.kind === 'boolean') {
inputError = '';
value = rightValue === 'true';
} else if (domain?.kind === 'date' || domain?.kind === 'time' || domain?.kind === 'datetime') {
if (rightValue === '') { inputError = 'Selecciona una fecha'; return; }
inputError = '';
} else {
inputError = '';
}
store.setRight(node.id, { kind: 'literal', value });
}
function onBetweenChange() {
const lo: Operand = { kind: 'literal', value: rightLo };
const hi: Operand = { kind: 'literal', value: rightHi };
store.setRight(node.id, [lo, hi]);
}
const baseSelect = `rounded border border-gray-300 bg-white px-2 py-1 text-sm
text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500`;
const baseInput = `rounded border border-gray-300 bg-white px-2 py-1 text-sm
text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500
min-w-0 flex-1`;
</script>
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2 rounded border px-2 py-1.5
{error ? 'border-red-300 bg-red-50' : 'border-gray-200 bg-white'}">
<!-- Variable -->
<select bind:value={selectedVar} onchange={onVarChange} class="{baseSelect} flex-1 min-w-0">
<option value="" disabled>— Variable —</option>
{#each store.scope as entry (entry.path)}
<option value={entry.path}>{entry.path}</option>
{/each}
</select>
<!-- Operador -->
<select bind:value={selectedOp} onchange={onOpChange} class={baseSelect}>
{#each availableOps as op}
<option value={op}>{opLabel(op)}</option>
{/each}
</select>
<!-- Valor — renderizado según dominio -->
{#if !scopeEntry}
<input type="text" disabled placeholder="selecciona variable" class="{baseInput} opacity-40" />
{:else if node.op === 'date-between'}
<input type="date" bind:value={rightLo} oninput={onBetweenChange} class={baseInput} />
<span class="text-xs text-gray-400 shrink-0">y</span>
<input type="date" bind:value={rightHi} oninput={onBetweenChange} class={baseInput} />
{:else if scopeEntry.domain.kind === 'option'}
{@const opts = (scopeEntry.domain as any).options ?? []}
{#if opts.length > 0}
<select bind:value={rightValue} onchange={onRightChange} class="{baseSelect} flex-1 min-w-0">
<option value="" disabled>— Valor —</option>
{#each opts as opt}
<option value={opt}>{opt}</option>
{/each}
</select>
{:else}
<input type="text" bind:value={rightValue} oninput={onRightChange}
placeholder="valor" class={baseInput} />
{/if}
{:else if scopeEntry.domain.kind === 'boolean'}
<select bind:value={rightValue} onchange={onRightChange} class="{baseSelect} flex-1 min-w-0">
<option value="" disabled>— Valor —</option>
<option value="true">Sí (true)</option>
<option value="false">No (false)</option>
</select>
{:else if scopeEntry.domain.kind === 'number' || scopeEntry.domain.kind === 'currency'}
<input type="number" bind:value={rightValue} oninput={onRightChange}
placeholder="0" class={baseInput} />
{:else if scopeEntry.domain.kind === 'date'}
<input type="date" bind:value={rightValue} oninput={onRightChange} class={baseInput} />
{:else if scopeEntry.domain.kind === 'time'}
<input type="time" bind:value={rightValue} oninput={onRightChange} class={baseInput} />
{:else if scopeEntry.domain.kind === 'datetime'}
<input type="datetime-local" bind:value={rightValue} oninput={onRightChange} class={baseInput} />
{:else}
<input type="text" bind:value={rightValue} oninput={onRightChange}
placeholder="valor" class={baseInput} />
{/if}
<!-- Eliminar -->
<button onclick={() => store.remove(node.id)}
class="shrink-0 rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
title="Eliminar condición">✕</button>
</div>
{#if inputError}
<p class="text-xs text-red-500 px-1">{inputError}</p>
{:else if error}
<p class="text-xs text-red-500 px-1">{error.message}</p>
{/if}
</div>

@ -0,0 +1,31 @@
<script lang="ts">
import type { ConditionBuilderStore } from '../stores/ConditionEditor.svelte.ts';
import ConditionNode from './ConditionNode.svelte';
interface Props {
store: ConditionBuilderStore;
onchange?: (compiled: unknown) => void;
onvalid?: (valid: boolean) => void;
}
let { store, onchange, onvalid }: Props = $props();
$effect(() => { onchange?.(store.compiled); });
$effect(() => { onvalid?.(store.isValid); });
</script>
<div class="flex flex-col gap-2">
{#if store.tautology.kind === 'always-true'}
<div class="rounded bg-amber-50 border border-amber-300 px-3 py-1.5 text-sm text-amber-700">
⚠️ Esta condición siempre es <strong>verdadera</strong>
</div>
{:else if store.tautology.kind === 'always-false'}
<div class="rounded bg-red-50 border border-red-300 px-3 py-1.5 text-sm text-red-700">
⚠️ Esta condición siempre es <strong>falsa</strong>
</div>
{/if}
<ConditionNode node={store.root} {store} depth={0} />
</div>

@ -0,0 +1,20 @@
<script lang="ts">
import type { ConditionBuilderStore } from '../stores/ConditionEditor.svelte';
import type { ConditionNode as ConditionNodeType } from '../lib/condition_editor';
import LogicNodeEditor from './LogicNodeEditor.svelte';
import CompareNodeEditor from './CompareNodeEditor.svelte';
interface Props {
node: ConditionNodeType;
store: ConditionBuilderStore;
depth: number;
}
let { node, store, depth }: Props = $props();
</script>
{#if node.kind === 'logic'}
<LogicNodeEditor {node} {store} {depth} />
{:else}
<CompareNodeEditor {node} {store} />
{/if}

@ -0,0 +1,64 @@
<script lang="ts">
import type { ConditionBuilderStore } from '../stores/ConditionEditor.svelte.ts';
import type {LogicNode, LogicOp} from '../lib/condition_editor.ts';
import ConditionNode from './ConditionNode.svelte';
interface Props {
node: LogicNode;
store: ConditionBuilderStore;
depth: number;
}
let { node, store, depth }: Props = $props();
let selectedOp = $state<LogicOp>(node.op);
function onOpChange() {
store.setLogicOp(node.id, selectedOp);
}
</script>
<div class="flex flex-col gap-1.5 rounded border border-gray-200 bg-gray-50 p-2">
<div class="flex items-center gap-2">
<select bind:value={selectedOp} onchange={onOpChange}
class="rounded border border-gray-300 bg-white px-2 py-1 text-sm font-medium
text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="and">Y (AND)</option>
<option value="or">O (OR)</option>
</select>
<span class="text-xs text-gray-400">se cumplen</span>
<div class="ml-auto flex gap-1">
<button
onclick={() => store.addCompare(node.id)}
class="rounded bg-blue-50 px-2 py-1 text-xs text-blue-600 hover:bg-blue-100 transition-colors"
>+ Condición</button>
<button
onclick={() => store.addLogic(node.id, 'and')}
class="rounded bg-purple-50 px-2 py-1 text-xs text-purple-600 hover:bg-purple-100 transition-colors"
>+ Grupo</button>
{#if depth > 0}
<button
onclick={() => store.remove(node.id)}
class="rounded bg-red-50 px-2 py-1 text-xs text-red-500 hover:bg-red-100 transition-colors"
>✕</button>
{/if}
</div>
</div>
{#if node.children.length === 0}
<p class="text-xs text-gray-400 italic px-1">Sin condiciones — añade una arriba</p>
{:else}
<div class="flex flex-col gap-1.5 pl-3 border-l-2 border-gray-200">
{#each node.children as child (child.id)}
<ConditionNode node={child} {store} depth={depth + 1} />
{/each}
</div>
{/if}
</div>

@ -0,0 +1,222 @@
<script lang="ts">
import type { DemoStore, DomainKind } from './demo.store.svelte';
import type { Att } from '@/vcen/lib/types';
import { createConditionBuilderStore } from '../../stores/ConditionEditor.svelte';
import ConditionBuilder from '../ConditionBuilder.svelte';
interface Props { store: DemoStore; }
let { store }: Props = $props();
const att = $derived(store.getSelectedAtt());
const DOMAIN_KINDS: { kind: DomainKind; label: string }[] = [
{ 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' },
];
// Options input
let optionsInput = $state('');
$effect(() => {
if (att?.value.domain.kind === 'option') {
optionsInput = ((att.value.domain as any).options ?? []).join(', ');
}
});
function saveOptions() {
if (!att) return;
const options = optionsInput.split(',').map(s => s.trim()).filter(Boolean);
store.updateAttOptions(att.id, options);
}
// Condition builder
const obj = $derived(store.getObj());
const cbStore = $derived(att ? createConditionBuilderStore(obj) : null);
$effect(() => {
if (!att || !cbStore) return;
if (att.rules?.rules?.length) {
cbStore.load(att.rules.rules[0].condition);
}
});
function saveCondition() {
if (!att || !cbStore) return;
store.setAttRules(att.id, cbStore.isValid ? [{
id: `rl_${att.id}` as any,
priority: 10,
condition: cbStore.compiled as any,
actions: [{ type: 'attribute', target: [], state: 'hidden' }],
}] : []);
}
let name = $state('');
$effect(() => { if (att) name = att.name; });
function onNameBlur() {
if (att && name !== att.name) store.updateAttName(att.id, name);
}
// Valor por defecto
let defaultVal = $state<any>('');
$effect(() => { if (att) defaultVal = att.value.value; });
function onDefaultValueChange() {
if (!att) return;
const kind = att.value.domain.kind;
let coerced: unknown = defaultVal;
if (kind === 'number' || kind === 'currency') coerced = Number(defaultVal);
if (kind === 'boolean') coerced = defaultVal === 'true' || defaultVal === true;
store.updateAttDefaultValue(att.id, coerced);
}
</script>
{#if !att}
<div class="flex items-center justify-center h-40 text-sm text-gray-500">
Selecciona un atributo para editarlo
</div>
{:else}
<div class="flex flex-col gap-5">
<!-- Nombre -->
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-gray-400 uppercase tracking-wider">Nombre</label>
<input type="text" bind:value={name} onblur={onNameBlur}
class="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" />
</div>
<!-- Dominio -->
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-gray-400 uppercase tracking-wider">Dominio</label>
<select
value={att.value.domain.kind}
onchange={e => store.updateAttDomain(att!.id, e.currentTarget.value as DomainKind)}
class="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"
>
{#each DOMAIN_KINDS as { kind, label }}
<option value={kind}>{label}</option>
{/each}
</select>
</div>
<!-- Opciones (solo si kind=option) -->
{#if att.value.domain.kind === 'option'}
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-gray-400 uppercase tracking-wider">
Opciones <span class="normal-case text-gray-600">(separadas por coma)</span>
</label>
<div class="flex gap-2">
<input type="text" bind:value={optionsInput}
placeholder="rojo, azul, verde"
class="flex-1 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" />
<button onclick={saveOptions}
class="rounded bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 transition-colors">
Guardar
</button>
</div>
<div class="flex flex-wrap gap-1 mt-1">
{#each (att.value.domain as any).options ?? [] as opt}
<span class="px-2 py-0.5 rounded-full bg-gray-800 text-xs text-gray-300
border border-gray-700">{opt}</span>
{/each}
</div>
</div>
{/if}
<!-- Valor por defecto -->
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-gray-400 uppercase tracking-wider">
Valor por defecto
</label>
{#if att.value.domain.kind === 'option'}
<select bind:value={defaultVal} onchange={onDefaultValueChange}
class="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">
{#each (att.value.domain as any).options ?? [] as opt}
<option value={opt}>{opt}</option>
{/each}
</select>
{:else if att.value.domain.kind === 'boolean'}
<select bind:value={defaultVal} onchange={onDefaultValueChange}
class="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">
<option value={true}>Sí (true)</option>
<option value={false}>No (false)</option>
</select>
{:else if att.value.domain.kind === 'number' || att.value.domain.kind === 'currency'}
<input type="number" bind:value={defaultVal} onchange={onDefaultValueChange}
class="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" />
{:else if att.value.domain.kind === 'date'}
<input type="date" bind:value={defaultVal} onchange={onDefaultValueChange}
class="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" />
{:else if att.value.domain.kind === 'time'}
<input type="time" bind:value={defaultVal} onchange={onDefaultValueChange}
class="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" />
{:else if att.value.domain.kind === 'datetime'}
<input type="datetime-local" bind:value={defaultVal} onchange={onDefaultValueChange}
class="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" />
{:else}
<input type="text" bind:value={defaultVal} onblur={onDefaultValueChange}
class="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" />
{/if}
<p class="text-xs text-gray-600">
Valor inicial cuando se carga el catálogo
</p>
</div>
<!-- Regla de visibilidad -->
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<label class="text-xs font-medium text-gray-400 uppercase tracking-wider">
Visible cuando...
</label>
{#if att.rules?.rules?.length}
<button onclick={() => store.setAttRules(att!.id, [])}
class="text-xs text-red-400 hover:text-red-300 transition-colors">
✕ Eliminar regla
</button>
{/if}
</div>
{#if cbStore}
<div class="rounded border border-gray-700 bg-gray-900 p-3">
<ConditionBuilder store={cbStore} />
</div>
<div class="flex items-center justify-between">
<p class="text-xs text-gray-500">
Si se cumple → atributo <strong class="text-gray-400">visible</strong>
</p>
<button onclick={saveCondition}
class="rounded px-3 py-1.5 text-sm transition-colors
{cbStore.isValid
? 'bg-green-700 text-white hover:bg-green-600'
: 'bg-gray-700 text-gray-500 cursor-not-allowed'}">
Aplicar
</button>
</div>
{/if}
</div>
</div>
{/if}

@ -0,0 +1,179 @@
<script lang="ts">
import { createDemoStore } from './demo.store.svelte';
import AttEditor from './AttEditor.svelte';
import RuntimePanel from './RuntimePanel.svelte';
import GraphPanel from './GraphPanel.svelte';
import type { DomainKind } from './demo.store.svelte';
const store = createDemoStore();
const DOMAIN_KINDS: { kind: DomainKind; label: string; color: string }[] = [
{ kind: 'string', label: 'Texto', color: 'bg-gray-700' },
{ kind: 'number', label: 'Número', color: 'bg-blue-900' },
{ kind: 'boolean', label: 'Bool', color: 'bg-purple-900' },
{ kind: 'option', label: 'Opción', color: 'bg-green-900' },
{ kind: 'date', label: 'Fecha', color: 'bg-amber-900' },
{ kind: 'currency', label: 'Moneda', color: 'bg-teal-900' },
];
const PANELS = [
{ id: 'editor', label: 'Editor' },
{ id: 'runtime', label: 'Runtime' },
{ id: 'graph', label: 'Grafo' },
{ id: 'json', label: 'JSON' },
] as const;
</script>
<div class="min-h-screen bg-gray-950 text-gray-100 flex flex-col">
<!-- Top bar -->
<header class="border-b border-gray-800 px-6 py-3 flex items-center gap-4">
<h1 class="text-sm font-semibold tracking-tight text-gray-200">
Catálogo Demo
</h1>
<span class="text-gray-700">|</span>
<span class="text-xs text-gray-500 font-mono">{store.cat.id}</span>
<!-- Tabs -->
<div class="ml-auto flex gap-1">
{#each PANELS as panel}
<button
onclick={() => store.activePanel = panel.id}
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
{store.activePanel === panel.id
? 'bg-gray-700 text-gray-100'
: 'text-gray-500 hover:text-gray-300'}"
>
{panel.label}
</button>
{/each}
</div>
<!-- Status -->
{#if store.session}
<span class="text-xs px-2 py-1 rounded border
{store.session.isValid()
? 'border-green-900 bg-green-950 text-green-400'
: 'border-red-900 bg-red-950 text-red-400'}">
{store.session.isValid() ? '✓ válido' : '✕ inválido'}
</span>
{:else}
<span class="text-xs px-2 py-1 rounded border border-red-900 bg-red-950 text-red-400">
⚠ error
</span>
{/if}
</header>
<!-- Layout -->
<div class="flex flex-1 overflow-hidden">
<!-- Sidebar — lista de atributos -->
<aside class="w-60 border-r border-gray-800 flex flex-col">
<div class="px-4 py-3 border-b border-gray-800">
<p class="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2">
Atributos
</p>
<!-- Botones añadir -->
<div class="grid grid-cols-3 gap-1">
{#each DOMAIN_KINDS as { kind, label, color }}
<button
onclick={() => store.addAtt(kind)}
class="rounded px-1.5 py-1 text-xs text-gray-300 transition-colors
hover:text-white {color} hover:brightness-125"
title="Añadir atributo {label}"
>
+ {label}
</button>
{/each}
</div>
</div>
<!-- Lista -->
<div class="flex-1 overflow-y-auto py-2">
{#each store.getAtts() as att (att.id)}
{@const es = store.session?.getEffectiveState([store.cat.id, store.getObj().id, att.id] as any)}
<button
onclick={() => { store.selectedAttId = att.id; store.activePanel = 'editor'; }}
class="w-full text-left px-4 py-2.5 flex items-center justify-between
transition-colors hover:bg-gray-800
{store.selectedAttId === att.id ? 'bg-gray-800 border-l-2 border-blue-500' : 'border-l-2 border-transparent'}"
>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-sm text-gray-200 truncate
{es?.state === 'hidden' ? 'line-through text-gray-600' : ''}">
{att.name}
</span>
<span class="text-xs text-gray-600 font-mono truncate">{att.id}</span>
</div>
<div class="flex items-center gap-1 shrink-0 ml-2">
<span class="text-xs px-1 py-0.5 rounded bg-gray-800 text-gray-500 border border-gray-700">
{att.value.domain.kind}
</span>
{#if att.rules?.rules?.length}
<span class="text-xs text-blue-400" title="Tiene reglas">⚡</span>
{/if}
</div>
</button>
<!-- Botón eliminar -->
<div class="px-4 pb-1 flex justify-end">
<button
onclick={() => store.removeAtt(att.id)}
class="text-xs text-gray-700 hover:text-red-400 transition-colors"
>
eliminar
</button>
</div>
{:else}
<p class="px-4 py-3 text-xs text-gray-600 italic">
Añade atributos con los botones de arriba
</p>
{/each}
</div>
</aside>
<!-- Panel principal -->
<main class="flex-1 overflow-y-auto p-6">
{#if store.activePanel === 'editor'}
<div class="max-w-xl">
<h2 class="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">
Editor de atributo
</h2>
<AttEditor {store} />
</div>
{:else if store.activePanel === 'runtime'}
<div class="max-w-lg">
<h2 class="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">
Runtime — prueba el motor
</h2>
<RuntimePanel {store} />
</div>
{:else if store.activePanel === 'graph'}
<div class="max-w-2xl">
<h2 class="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">
Grafo de dependencias
</h2>
<GraphPanel {store} />
</div>
{:else if store.activePanel === 'json'}
<div class="max-w-2xl">
<h2 class="text-sm font-medium text-gray-400 uppercase tracking-wider mb-4">
Cat JSON
</h2>
<pre class="rounded border border-gray-800 bg-gray-900 p-4
text-xs font-mono text-gray-400 overflow-auto leading-relaxed">
{JSON.stringify(store.cat, null, 2)}
</pre>
</div>
{/if}
</main>
</div>
</div>

@ -0,0 +1,159 @@
<script lang="ts">
import { createConditionBuilderStore } from '../../stores/ConditionEditor.svelte.js';
import ConditionBuilder from '../ConditionBuilder.svelte';
import type { Obj } from '@/vcen/lib/types';
import type { JsonLogicValue } from '@/jslg';
// ── Mock Obj con atributos de ejemplo ────────────────────────────────────
const obj: Obj = {
id: 'ob_bmw' as Obj['id'],
name: 'BMW 430i',
atts: [
{
id: 'at_color' as any,
name: 'Color',
value: { domain: { kind: 'option', options: ['rojo', 'azul', 'negro', 'blanco'] } as any, value: 'rojo' },
},
{
id: 'at_acabado' as any,
name: 'Acabado',
value: { domain: { kind: 'option', options: ['base', 'sport', 'lujo'] } as any, value: 'base' },
},
{
id: 'at_precio' as any,
name: 'Precio',
value: { domain: { kind: 'number' } as any, value: 0 },
},
{
id: 'at_activo' as any,
name: 'Activo',
value: { domain: { kind: 'boolean' } as any, value: true },
},
{
id: 'at_entrega' as any,
name: 'Fecha entrega',
value: { domain: { kind: 'date' } as any, value: '' },
},
],
secs: [
{
id: 'sc_motor' as any,
name: 'Motor',
atts: [
{
id: 'at_fuel' as any,
name: 'Combustible',
value: { domain: { kind: 'option', options: ['gasolina', 'diesel', 'electrico'] } as any, value: 'gasolina' },
},
{
id: 'at_cv' as any,
name: 'CV',
value: { domain: { kind: 'number' } as any, value: 0 },
},
],
},
],
};
// ── Store ─────────────────────────────────────────────────────────────────
const store = createConditionBuilderStore(obj);
// ── Estado local ──────────────────────────────────────────────────────────
let compiled = $state<JsonLogicValue>(store.compiled);
let isValid = $state(store.isValid);
function onchange(value: unknown) {
compiled = value as JsonLogicValue;
}
function onvalid(valid: boolean) {
isValid = valid;
}
</script>
<!-- ── Layout ──────────────────────────────────────────────────────────────── -->
<div class="min-h-screen bg-gray-950 text-gray-100 p-6">
<div class="max-w-5xl mx-auto flex flex-col gap-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-lg font-semibold tracking-tight">ConditionBuilder</h1>
<p class="text-sm text-gray-500">Demo — {obj.name}</p>
</div>
<div class="flex items-center gap-3">
<span class="text-xs px-2.5 py-1 rounded font-medium
{isValid
? 'bg-green-950 text-green-400 border border-green-900'
: 'bg-red-950 text-red-400 border border-red-900'}">
{isValid ? '✓ válida' : '✕ inválida'}
</span>
<button
onclick={() => store.reset()}
class="text-xs px-3 py-1.5 rounded border border-gray-700
text-gray-400 hover:border-gray-500 hover:text-gray-200
transition-colors"
>
↺ Reset
</button>
</div>
</div>
<!-- Grid -->
<div class="grid grid-cols-[1fr_300px] gap-4 items-start">
<!-- Editor -->
<div class="bg-gray-900 border border-gray-800 rounded-lg p-4">
<ConditionBuilder {store} {onchange} {onvalid} />
</div>
<!-- Panel derecho -->
<div class="flex flex-col gap-4">
<!-- Scope -->
<div class="bg-gray-900 border border-gray-800 rounded-lg p-4 flex flex-col gap-2">
<p class="text-xs font-medium text-gray-400 uppercase tracking-wider">Variables</p>
<div class="flex flex-col gap-1">
{#each store.scope as entry (entry.path)}
<div class="flex items-center justify-between px-2 py-1.5
rounded bg-gray-800 border border-gray-700">
<span class="text-xs font-mono text-gray-300">{entry.path}</span>
<span class="text-xs px-1.5 py-0.5 rounded
{entry.domain.kind === 'number' ? 'bg-blue-950 text-blue-400' :
entry.domain.kind === 'boolean' ? 'bg-purple-950 text-purple-400' :
entry.domain.kind === 'date' ? 'bg-amber-950 text-amber-400' :
'bg-green-950 text-green-400'}">
{entry.domain.kind}
</span>
</div>
{/each}
</div>
</div>
<!-- Output -->
<div class="bg-gray-900 border border-gray-800 rounded-lg p-4 flex flex-col gap-2">
<p class="text-xs font-medium text-gray-400 uppercase tracking-wider">JsonLogicValue</p>
<pre class="text-xs font-mono text-gray-400 bg-gray-950 rounded p-3
overflow-auto max-h-64 leading-relaxed">{JSON.stringify(compiled, null, 2)}</pre>
</div>
<!-- Errores -->
{#if store.errors.length > 0}
<div class="bg-gray-900 border border-red-900 rounded-lg p-4 flex flex-col gap-2">
<p class="text-xs font-medium text-red-400 uppercase tracking-wider">Errores</p>
<div class="flex flex-col gap-1">
{#each store.errors as error}
<p class="text-xs text-red-400">• {error.message}</p>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
</div>

@ -0,0 +1,147 @@
<script lang="ts">
import type { DemoStore } from './demo.store.svelte';
import { getTransitiveAffected, getDependencies } from '@/vcen/lib/graph';
interface Props { store: DemoStore; }
let { store }: Props = $props();
const graph = $derived(store.graph);
const atts = $derived(store.getAtts());
const catId = $derived(store.cat.id);
const objId = $derived(store.getObj().id);
function attKey(attId: string) {
return `${catId}/${objId}/${attId}`;
}
function getDeps(attId: string) {
if (!graph) return [];
return getDependencies(graph, [catId, objId, attId] as any)
.map(p => p[p.length - 1]); // último segmento = attId
}
function getAffected(attId: string) {
if (!graph) return [];
return getTransitiveAffected(graph, [catId, objId, attId] as any)
.map(p => p[p.length - 1]);
}
function attName(attId: string) {
return atts.find(a => a.id === attId)?.name ?? attId;
}
let hoveredId = $state<string | null>(null);
</script>
{#if !graph}
<div class="rounded border border-red-900 bg-red-950 px-4 py-3 text-sm text-red-400">
⚠ {store.graphError ?? 'Grafo no disponible'}
</div>
{:else if atts.length === 0}
<p class="text-sm text-gray-500">Añade atributos para ver el grafo</p>
{:else}
<div class="flex flex-col gap-3">
<p class="text-xs text-gray-500">
Pasa el cursor sobre un atributo para ver sus dependencias
</p>
{#each atts as att (att.id)}
{@const deps = getDeps(att.id)}
{@const affected = getAffected(att.id)}
{@const isHovered = hoveredId === att.id}
{@const isDep = hoveredId ? getDeps(hoveredId).includes(att.id) : false}
{@const isAffected = hoveredId ? getAffected(hoveredId).includes(att.id) : false}
<div
role="button" tabindex="0"
onmouseenter={() => hoveredId = att.id}
onmouseleave={() => hoveredId = null}
class="rounded border p-3 transition-all cursor-default
{isHovered ? 'border-blue-600 bg-blue-950' :
isDep ? 'border-amber-700 bg-amber-950' :
isAffected ? 'border-green-700 bg-green-950' :
'border-gray-800 bg-gray-900'}"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-sm font-medium
{isHovered ? 'text-blue-300' :
isDep ? 'text-amber-300' :
isAffected ? 'text-green-300' :
'text-gray-200'}">
{att.name}
</span>
<span class="font-mono text-xs text-gray-600">{att.id}</span>
</div>
<div class="flex gap-2">
{#if deps.length > 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-amber-950 text-amber-400 border border-amber-900">
depende de {deps.length}
</span>
{/if}
{#if affected.length > 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-green-950 text-green-400 border border-green-900">
afecta a {affected.length}
</span>
{/if}
{#if deps.length === 0 && affected.length === 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-600 border border-gray-700">
independiente
</span>
{/if}
</div>
</div>
{#if isHovered}
<div class="mt-2 flex flex-col gap-1.5 text-xs">
{#if deps.length > 0}
<div class="flex items-center gap-1 flex-wrap">
<span class="text-amber-500">← depende de:</span>
{#each deps as depId}
<span class="px-1.5 py-0.5 rounded bg-amber-950 text-amber-300 border border-amber-900">
{attName(depId)}
</span>
{/each}
</div>
{/if}
{#if affected.length > 0}
<div class="flex items-center gap-1 flex-wrap">
<span class="text-green-500">→ afecta a:</span>
{#each affected as affId}
<span class="px-1.5 py-0.5 rounded bg-green-950 text-green-300 border border-green-900">
{attName(affId)}
</span>
{/each}
</div>
{/if}
{#if att.rules?.rules?.length}
<div class="text-gray-500">
{att.rules.rules.length} regla{att.rules.rules.length > 1 ? 's' : ''}
</div>
{/if}
</div>
{/if}
</div>
{/each}
<!-- Leyenda -->
<div class="flex gap-4 mt-1">
<div class="flex items-center gap-1.5 text-xs text-gray-500">
<div class="w-3 h-3 rounded border border-blue-600 bg-blue-950"></div>
seleccionado
</div>
<div class="flex items-center gap-1.5 text-xs text-gray-500">
<div class="w-3 h-3 rounded border border-amber-700 bg-amber-950"></div>
input (del que depende)
</div>
<div class="flex items-center gap-1.5 text-xs text-gray-500">
<div class="w-3 h-3 rounded border border-green-700 bg-green-950"></div>
afectado
</div>
</div>
</div>
{/if}

@ -0,0 +1,167 @@
<script lang="ts">
import type { DemoStore } from './demo.store.svelte';
interface Props { store: DemoStore; }
let { store }: Props = $props();
const session = $derived(store.session);
const result = $derived(store.result); // ← reactivo via subscribe
const atts = $derived(store.getAtts());
const catId = $derived(store.cat.id);
const objId = $derived(store.getObj().id);
function attPath(attId: string) {
return [catId, objId, attId] as any;
}
function getEs(attId: string) {
return result?.effectiveState.get(`${catId}/${objId}/${attId}`);
}
function getValue(attId: string) {
const es = getEs(attId);
return es?.derivedValue?.value ?? atts.find(a => a.id === attId)?.value.value;
}
function onChange(attId: string, value: unknown) {
session?.setValue(attPath(attId), value);
}
function stateBadge(state: string) {
return {
available: 'bg-green-950 text-green-400 border-green-900',
hidden: 'bg-gray-800 text-gray-500 border-gray-700',
required: 'bg-blue-950 text-blue-400 border-blue-900',
forbidden: 'bg-red-950 text-red-400 border-red-900',
}[state] ?? 'bg-gray-800 text-gray-400 border-gray-700';
}
</script>
{#if !session || !result}
<div class="rounded border border-red-900 bg-red-950 px-4 py-3 text-sm text-red-400">
⚠ {store.graphError ?? 'Error al crear la sesión'}
</div>
{:else}
<div class="flex flex-col gap-3">
<!-- Estado global -->
<div class="flex items-center justify-between px-3 py-2 rounded border
{result.valid
? 'border-green-900 bg-green-950'
: 'border-red-900 bg-red-950'}">
<span class="text-sm font-medium
{result.valid ? 'text-green-400' : 'text-red-400'}">
{result.valid ? '✓ Configuración válida' : '✕ Configuración inválida'}
</span>
<button onclick={() => session!.reset()}
class="text-xs text-gray-500 hover:text-gray-300 transition-colors">
↺ Reset
</button>
</div>
<!-- Atributos -->
{#each atts as att (att.id)}
{@const es = getEs(att.id)}
{@const currentValue = getValue(att.id)}
{@const domain = att.value.domain}
<div class="rounded border border-gray-800 bg-gray-900 p-3 flex flex-col gap-2
{es?.state === 'hidden' ? 'opacity-40' : ''}">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-200">{att.name}</span>
<span class="text-xs text-gray-600 font-mono">{att.id}</span>
</div>
{#if es}
<span class="text-xs px-2 py-0.5 rounded border {stateBadge(es.state)}">
{es.state}
</span>
{/if}
</div>
<!-- Input según dominio -->
{#if es?.state !== 'hidden' && es?.state !== 'forbidden'}
<div class="flex items-center gap-2">
{#if domain.kind === 'boolean'}
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox"
checked={currentValue as boolean}
onchange={e => onChange(att.id, e.currentTarget.checked)}
class="rounded border-gray-600" />
<span class="text-sm text-gray-300">
{currentValue ? 'Sí' : 'No'}
</span>
</label>
{:else if domain.kind === 'option'}
<select
value={currentValue as string}
onchange={e => onChange(att.id, e.currentTarget.value)}
class="flex-1 rounded border border-gray-700 bg-gray-800 px-2 py-1.5
text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{#each (domain as any).options ?? [] as opt}
<option value={opt}
disabled={es?.forbiddenValues?.includes(opt)}>
{opt}{es?.forbiddenValues?.includes(opt) ? ' ✕' : ''}
</option>
{/each}
</select>
{:else if domain.kind === 'number' || domain.kind === 'currency'}
<input type="number"
value={currentValue as number}
oninput={e => onChange(att.id, Number(e.currentTarget.value))}
class="flex-1 rounded border border-gray-700 bg-gray-800 px-2 py-1.5
text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500" />
{:else if domain.kind === 'date'}
<input type="date"
value={currentValue as string}
oninput={e => onChange(att.id, e.currentTarget.value)}
class="flex-1 rounded border border-gray-700 bg-gray-800 px-2 py-1.5
text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500" />
{:else if domain.kind === 'time'}
<input type="time"
value={currentValue as string}
oninput={e => onChange(att.id, e.currentTarget.value)}
class="flex-1 rounded border border-gray-700 bg-gray-800 px-2 py-1.5
text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500" />
{:else}
<input type="text"
value={currentValue as string}
oninput={e => onChange(att.id, e.currentTarget.value)}
class="flex-1 rounded border border-gray-700 bg-gray-800 px-2 py-1.5
text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500" />
{/if}
{#if es?.derivedValue !== undefined}
<span class="text-xs text-amber-400 shrink-0">
⟵ derivado: {JSON.stringify(es.derivedValue.value)}
</span>
{/if}
</div>
{:else if es?.state === 'forbidden'}
<p class="text-xs text-red-400">Este atributo no está disponible</p>
{/if}
<!-- Mensajes -->
{#if es?.messages?.length}
{#each es.messages as msg}
<p class="text-xs {msg.level === 'error' ? 'text-red-400' : 'text-amber-400'}">
{msg.level === 'error' ? '✕' : '⚠'} {msg.message}
</p>
{/each}
{/if}
</div>
{/each}
</div>
{/if}

@ -0,0 +1,245 @@
import { createSession } from '@/vcen/lib/session';
import { buildDependencyGraph } from '@/vcen/lib/graph';
import type { Cat, Obj, Att, Sec } from '@/vcen/lib/types';
import type { Rule } from '@/vcen/lib/types';
import type { DependencyGraph } from '@/vcen/lib/types';
import type { Session } from '@/vcen/lib/session';
import {evaluateCat} from "@/vcen/lib/engine.ts";
// ─── Types ────────────────────────────────────────────────────────────────────
export type DomainKind = 'string' | 'number' | 'boolean' | 'option' | 'date' | 'time' | 'datetime' | 'currency';
// ─── Store ────────────────────────────────────────────────────────────────────
export function createDemoStore() {
// Cat mutable
let cat = $state<Cat>(makeFreshCat());
// Runtime session — se reconstruye cuando cambia el cat
let session = $state<Session | null>(null);
let graphError = $state<string | null>(null);
// Grafo para visualización
let graph = $state<DependencyGraph | null>(null);
// Resultado reactivo del motor — se actualiza con cada setValue
let result = $state(evaluateCat(cat));
// Panel activo
let activePanel = $state<'editor' | 'runtime' | 'graph' | 'json'>('editor');
// Att seleccionado en el editor
let selectedAttId = $state<string | null>(null);
// ── Rebuild session ───────────────────────────────────────────────────────
function rebuildSession() {
try {
graph = buildDependencyGraph(cat);
session = createSession(cat);
result = session.getResult();
graphError = null;
// Suscribir para mantener result reactivo
session.subscribe(r => { result = r; });
} catch (e: any) {
graphError = e.message ?? 'Error en el grafo de dependencias';
session = null;
graph = null;
}
}
// ── Cat mutations ─────────────────────────────────────────────────────────
function addAtt(kind: DomainKind) {
const id = `at_${Date.now()}` as Att['id'];
const options: string[] = kind === 'option' ? ['opcion1', 'opcion2'] : [];
const att: Att = {
id,
name: 'Nuevo atributo',
value: { domain: makeDomain(kind, options), value: defaultValue(kind, options) },
};
cat = {
...cat,
objs: cat.objs.map((o, i) => i === 0
? { ...o, atts: [...o.atts, att] }
: o
),
};
selectedAttId = id;
rebuildSession();
}
function removeAtt(attId: string) {
cat = {
...cat,
objs: cat.objs.map(o => ({
...o,
atts: o.atts.filter(a => a.id !== attId),
secs: o.secs.map(s => ({
...s,
atts: s.atts.filter(a => a.id !== attId),
})),
})),
};
if (selectedAttId === attId) selectedAttId = null;
rebuildSession();
}
function updateAttName(attId: string, name: string) {
cat = updateAtt(cat, attId, a => ({ ...a, name }));
rebuildSession();
}
function updateAttDomain(attId: string, kind: DomainKind) {
cat = updateAtt(cat, attId, a => {
const options = kind === 'option' ? ((a.value.domain as any).options ?? []) : [];
return {
...a,
value: { domain: makeDomain(kind, options), value: defaultValue(kind, options) },
};
});
rebuildSession();
}
function updateAttOptions(attId: string, options: string[]) {
cat = updateAtt(cat, attId, a => {
const currentVal = a.value.value;
const value = options.includes(currentVal as string) ? currentVal : (options[0] ?? '');
return {
...a,
value: {
domain: { ...a.value.domain, options } as any,
value,
},
};
});
rebuildSession();
}
function updateAttDefaultValue(attId: string, value: unknown) {
cat = updateAtt(cat, attId, a => ({
...a,
value: { ...a.value, value: value as any },
}));
rebuildSession();
}
function setAttRules(attId: string, rules: Rule[]) {
cat = updateAtt(cat, attId, a => ({
...a,
rules: rules.length > 0 ? { rules } : undefined,
}));
rebuildSession();
}
// ── Runtime ───────────────────────────────────────────────────────────────
function setRuntimeValue(attPath: any, value: unknown) {
session?.setValue(attPath, value);
}
// ── Queries ───────────────────────────────────────────────────────────────
function getAtts(): Att[] {
return cat.objs[0]?.atts ?? [];
}
function getSelectedAtt(): Att | null {
if (!selectedAttId) return null;
return getAtts().find(a => a.id === selectedAttId) ?? null;
}
function getObj(): Obj {
return cat.objs[0]!;
}
// ── Init ──────────────────────────────────────────────────────────────────
rebuildSession();
return {
get cat() { return cat; },
get session() { return session; },
get result() { return result; },
get graph() { return graph; },
get graphError() { return graphError; },
get activePanel() { return activePanel; },
get selectedAttId(){ return selectedAttId; },
set activePanel(v: typeof activePanel) { activePanel = v; },
set selectedAttId(v: string | null) { selectedAttId = v; },
getAtts,
getSelectedAtt,
getObj,
addAtt,
removeAtt,
updateAttName,
updateAttDomain,
updateAttOptions,
setAttRules,
updateAttDefaultValue,
setRuntimeValue,
rebuildSession,
};
}
export type DemoStore = ReturnType<typeof createDemoStore>;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeFreshCat(): Cat {
return {
id: 'ct_demo' as Cat['id'],
name: 'Catálogo demo',
atts: [],
objs: [{
id: 'ob_demo' as Obj['id'],
name: 'Objeto demo',
atts: [
{
id: 'at_color' as Att['id'],
name: 'Color',
value: { domain: { kind: 'option', options: ['rojo', 'azul', 'negro'] } as any, value: 'rojo' },
},
{
id: 'at_precio' as Att['id'],
name: 'Precio',
value: { domain: { kind: 'number' } as any, value: 0 },
},
],
secs: [],
}],
};
}
function makeDomain(kind: DomainKind, options: string[] = []): any {
if (kind === 'option') return { kind, options };
return { kind };
}
function defaultValue(kind: DomainKind, options: string[] = []): any {
if (kind === 'number' || kind === 'currency') return 0;
if (kind === 'boolean') return false;
if (kind === 'option') return options[0] ?? '';
if (kind === 'date') return new Date().toISOString().split('T')[0];
if (kind === 'time') return '08:00';
if (kind === 'datetime') return new Date().toISOString().slice(0, 16);
return '';
}
function updateAtt(cat: Cat, attId: string, fn: (a: Att) => Att): Cat {
return {
...cat,
objs: cat.objs.map(o => ({
...o,
atts: o.atts.map(a => a.id === attId ? fn(a) : a),
secs: o.secs.map(s => ({
...s,
atts: s.atts.map(a => a.id === attId ? fn(a) : a),
})),
})),
};
}

@ -0,0 +1,412 @@
import type { JsonLogicValue } from '@/jslg';
import type { Att, Sec, Obj , AttDomain } from '@/vcen/lib/types';
// ─────────────────────────────────────────────────────────────────────────────
// CONDITION TREE — representación interna del editor
// ─────────────────────────────────────────────────────────────────────────────
export type LogicOp = 'and' | 'or';
export type CompareOp = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'date-before' | 'date-after' | 'date-between';
export interface LogicNode {
kind: 'logic';
id: string;
op: LogicOp;
children: ConditionNode[];
}
export interface CompareNode {
kind: 'compare';
id: string;
op: CompareOp;
left: Operand;
right: Operand | [Operand, Operand]; // date-between necesita dos bounds
}
export type ConditionNode = LogicNode | CompareNode;
export interface VarOperand {
kind: 'var';
path: string; // relativo al scope: "at_color", "sc_motor/at_fuel"
}
export interface LiteralOperand {
kind: 'literal';
value: unknown;
}
export type Operand = VarOperand | LiteralOperand;
// ─────────────────────────────────────────────────────────────────────────────
// SCOPE — atts visibles desde la regla
// ─────────────────────────────────────────────────────────────────────────────
export interface ScopeEntry {
path: string; // relativo al contenedor: "at_color", "sc_motor/at_fuel"
att: Att;
domain: AttDomain;
}
/**
* Construye el scope de variables disponibles para una regla.
* El scope incluye todos los atts del contenedor y sus secciones hijas.
*
* @param container - Obj o Sec que contiene la regla
*/
export function buildScope(container: Obj | Sec): ScopeEntry[] {
const entries: ScopeEntry[] = [];
const atts = 'atts' in container ? container.atts : [];
for (const att of atts) {
entries.push({ path: att.id, att, domain: att.value.domain });
}
if ('secs' in container) {
for (const sec of container.secs) {
for (const att of sec.atts) {
entries.push({
path: `${sec.id}/${att.id}`,
att,
domain: att.value.domain,
});
}
}
}
return entries;
}
// ─────────────────────────────────────────────────────────────────────────────
// OPERATORS — válidos según el dominio
// ─────────────────────────────────────────────────────────────────────────────
const OPERATORS_BY_DOMAIN: Record<AttDomain['kind'], CompareOp[]> = {
string: ['==', '!='],
number: ['==', '!=', '>', '<', '>=', '<='],
boolean: ['==', '!='],
currency: ['==', '!=', '>', '<', '>=', '<='],
option: ['==', '!='],
date: ['==', '!=', 'date-before', 'date-after', 'date-between'],
time: ['==', '!=', 'date-before', 'date-after', 'date-between'],
datetime: ['==', '!=', 'date-before', 'date-after', 'date-between'],
};
/**
* Operadores válidos para un dominio dado.
*/
export function getValidOperators(domain: AttDomain): CompareOp[] {
return OPERATORS_BY_DOMAIN[domain.kind] ?? ['==', '!='];
}
/**
* `true` si el operador es compatible con el dominio.
*/
export function isOperatorValid(op: CompareOp, domain: AttDomain): boolean {
return getValidOperators(domain).includes(op);
}
// ─────────────────────────────────────────────────────────────────────────────
// TAUTOLOGY DETECTION
// ─────────────────────────────────────────────────────────────────────────────
export type TautologyResult =
| { kind: 'always-true' }
| { kind: 'always-false' }
| { kind: 'unknown' }
/**
* Detecta si un nodo es una tautología o contradicción evaluable en build time.
* Solo aplica cuando ambos operandos son literales.
*/
export function detectTautology(node: ConditionNode): TautologyResult {
if (node.kind === 'logic') {
const childResults = node.children.map(detectTautology);
if (node.op === 'and') {
if (childResults.some(r => r.kind === 'always-false'))
return { kind: 'always-false' };
if (childResults.every(r => r.kind === 'always-true'))
return { kind: 'always-true' };
}
if (node.op === 'or') {
if (childResults.some(r => r.kind === 'always-true'))
return { kind: 'always-true' };
if (childResults.every(r => r.kind === 'always-false'))
return { kind: 'always-false' };
}
return { kind: 'unknown' };
}
// CompareNode — solo evaluable si ambos son literales
if (node.left.kind !== 'literal') return { kind: 'unknown' };
if (node.op === 'date-between') {
if (!Array.isArray(node.right)) return { kind: 'unknown' };
if (node.right[0].kind !== 'literal' || node.right[1].kind !== 'literal')
return { kind: 'unknown' };
const result = evalCompare(node.op, node.left.value, [node.right[0].value, node.right[1].value]);
return result ? { kind: 'always-true' } : { kind: 'always-false' };
}
if (Array.isArray(node.right) || node.right.kind !== 'literal')
return { kind: 'unknown' };
const result = evalCompare(node.op, node.left.value, node.right.value);
return result ? { kind: 'always-true' } : { kind: 'always-false' };
}
function evalCompare(op: CompareOp, a: unknown, b: unknown | [unknown, unknown]): boolean {
switch (op) {
case '==': return a === b;
case '!=': return a !== b;
case '>': return (a as number) > (b as number);
case '<': return (a as number) < (b as number);
case '>=': return (a as number) >= (b as number);
case '<=': return (a as number) <= (b as number);
case 'date-before': return String(a) < String(b);
case 'date-after': return String(a) > String(b);
case 'date-between': {
const [start, end] = b as [unknown, unknown];
return String(a) >= String(start) && String(a) <= String(end);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// COMPILE — árbol → JsonLogicValue
// ─────────────────────────────────────────────────────────────────────────────
/**
* Compila un árbol de condición a `JsonLogicValue`.
*/
export function compileCondition(node: ConditionNode): JsonLogicValue {
if (node.kind === 'logic') {
return {
[node.op]: node.children.map(compileCondition),
} as JsonLogicValue;
}
const left = compileOperand(node.left);
if (node.op === 'date-between') {
if (!Array.isArray(node.right)) throw new Error('date-between requires two bounds');
return {
'date-between': [left, compileOperand(node.right[0]), compileOperand(node.right[1])],
} as JsonLogicValue;
}
const right = Array.isArray(node.right)
? compileOperand(node.right[0])
: compileOperand(node.right);
return { [node.op]: [left, right] } as JsonLogicValue;
}
function compileOperand(operand: Operand): JsonLogicValue {
if (operand.kind === 'var') return { var: operand.path } as JsonLogicValue;
return operand.value as JsonLogicValue;
}
// ─────────────────────────────────────────────────────────────────────────────
// PARSE — JsonLogicValue → árbol
// ─────────────────────────────────────────────────────────────────────────────
let _idCounter = 0;
function newId(): string { return `node_${++_idCounter}`; }
/**
* Parsea un `JsonLogicValue` a árbol de condición editable.
* Útil para cargar reglas existentes en el editor.
*/
export function parseCondition(value: JsonLogicValue): ConditionNode {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
// Literal booleano como condición raíz — envolver en compare
return makeCompareNode('==', { kind: 'literal', value }, { kind: 'literal', value: true });
}
const op = Object.keys(value as object)[0] as string;
const args = (value as any)[op];
const argArr = Array.isArray(args) ? args : [args];
if (op === 'and' || op === 'or') {
return {
kind: 'logic',
id: newId(),
op: op as LogicOp,
children: argArr.map(parseCondition),
};
}
if (op === 'date-between') {
return {
kind: 'compare',
id: newId(),
op: 'date-between',
left: parseOperand(argArr[0]),
right: [parseOperand(argArr[1]), parseOperand(argArr[2])],
};
}
if (isCompareOp(op)) {
return {
kind: 'compare',
id: newId(),
op: op as CompareOp,
left: parseOperand(argArr[0]),
right: parseOperand(argArr[1]),
};
}
// Operador desconocido — tratar como opaco
return makeCompareNode('==', { kind: 'literal', value }, { kind: 'literal', value: true });
}
function parseOperand(value: unknown): Operand {
if (value !== null && typeof value === 'object' && !Array.isArray(value) && 'var' in value) {
return { kind: 'var', path: (value as any).var as string };
}
return { kind: 'literal', value };
}
function isCompareOp(op: string): op is CompareOp {
return ['==', '!=', '>', '<', '>=', '<=', 'date-before', 'date-after', 'date-between'].includes(op);
}
// ─────────────────────────────────────────────────────────────────────────────
// BUILDER HELPERS — para crear nodos desde la UI
// ─────────────────────────────────────────────────────────────────────────────
export function makeLogicNode(op: LogicOp, children: ConditionNode[] = []): LogicNode {
return { kind: 'logic', id: newId(), op, children };
}
export function makeCompareNode(
op: CompareOp,
left: Operand = { kind: 'literal', value: '' },
right: Operand | [Operand, Operand] = { kind: 'literal', value: '' },
): CompareNode {
return { kind: 'compare', id: newId(), op, left, right };
}
// ─────────────────────────────────────────────────────────────────────────────
// MUTATION HELPERS — operaciones inmutables sobre el árbol
// ─────────────────────────────────────────────────────────────────────────────
/**
* Reemplaza un nodo por `id` en el árbol. Devuelve un nuevo árbol.
*/
export function replaceNode(
root: ConditionNode,
id: string,
newNode: ConditionNode,
): ConditionNode {
if (root.id === id) return newNode;
if (root.kind === 'logic') {
return {
...root,
children: root.children.map(c => replaceNode(c, id, newNode)),
};
}
return root;
}
/**
* Elimina un nodo por `id`. Si el padre queda con 0 hijos, también se elimina.
*/
export function removeNode(root: ConditionNode, id: string): ConditionNode | null {
if (root.id === id) return null;
if (root.kind === 'logic') {
const children = root.children
.map(c => removeNode(c, id))
.filter((c): c is ConditionNode => c !== null);
if (children.length === 0) return null;
return { ...root, children };
}
return root;
}
/**
* Añade un nodo hijo a un `LogicNode` por `id`.
*/
export function addChild(
root: ConditionNode,
parentId: string,
child: ConditionNode,
): ConditionNode {
if (root.id === parentId && root.kind === 'logic') {
return { ...root, children: [...root.children, child] };
}
if (root.kind === 'logic') {
return {
...root,
children: root.children.map(c => addChild(c, parentId, child)),
};
}
return root;
}
// ─────────────────────────────────────────────────────────────────────────────
// VALIDATION
// ─────────────────────────────────────────────────────────────────────────────
export interface ConditionError {
nodeId: string;
message: string;
}
/**
* Valida el árbol de condición contra el scope disponible.
* Devuelve lista de errores (vacía si todo está bien).
*/
export function validateCondition(
node: ConditionNode,
scope: ScopeEntry[],
): ConditionError[] {
const errors: ConditionError[] = [];
function walk(n: ConditionNode) {
if (n.kind === 'logic') {
if (n.children.length === 0) {
errors.push({ nodeId: n.id, message: `Operador "${n.op}" sin condiciones hijas` });
}
n.children.forEach(walk);
return;
}
// Validar left
if (n.left.kind === 'var') {
const left = n.left as VarOperand;
const entry = scope.find(e => e.path === left.path);
if (!entry) {
errors.push({ nodeId: n.id, message: `Variable "${left.path}" no encontrada en el scope` });
} else if (!isOperatorValid(n.op, entry.domain)) {
errors.push({ nodeId: n.id, message: `Operador "${n.op}" no es válido para el dominio "${entry.domain.kind}"` });
}
}
// Validar right (para date-between)
if (n.op === 'date-between' && Array.isArray(n.right)) {
const [lo, hi] = n.right as [Operand, Operand];
if (lo.kind === 'var') {
const entry = scope.find(e => e.path === lo.path);
if (!entry) errors.push({ nodeId: n.id, message: `Variable bound inferior no encontrada` });
}
if (hi.kind === 'var') {
const entry = scope.find(e => e.path === hi.path);
if (!entry) errors.push({ nodeId: n.id, message: `Variable bound superior no encontrada` });
}
}
}
walk(node);
return errors;
}

@ -0,0 +1,11 @@
import { mount } from 'svelte';
import App from './App.svelte';
const app = mount(App, {
target: document.getElementById('app')!
});
export default app;

@ -0,0 +1,194 @@
import type { JsonLogicValue } from '@/jslg';
import type { Obj, Sec } from '@/vcen/lib/types';
import type {
ScopeEntry,
ConditionNode,
ConditionError,
CompareOp,
LogicOp,
Operand
} from '../lib/condition_editor';
import {
buildScope,
compileCondition,
parseCondition,
validateCondition,
detectTautology,
makeLogicNode,
makeCompareNode,
replaceNode,
removeNode,
addChild,
getValidOperators,
} from '../lib/condition_editor';
// ─────────────────────────────────────────────────────────────────────────────
// STORE
// ─────────────────────────────────────────────────────────────────────────────
/**
* Adaptador Svelte 5 para el editor de condiciones JsonLogic.
*
* Usa runas (`$state`, `$derived`) para que los componentes Svelte
* reaccionen automáticamente a los cambios del árbol de condición.
*
* ## Uso
*
* ```svelte
* <script>
* import { createConditionBuilderStore } from './condition-editor.svelte';
*
* const store = createConditionBuilderStore(obj, existingCondition);
* </script>
*
* {#if store.tautology.kind === 'always-true'}
* <Warning>Esta condición siempre es verdadera</Warning>
* {/if}
*
* <button onclick={() => store.addCompare(store.root.id)}>
* + Condición
* </button>
* ```
*/
export function createConditionBuilderStore(
container: Obj | Sec,
initial?: JsonLogicValue,
) {
// ── Estado reactivo ───────────────────────────────────────────────────────
let root = $state<ConditionNode>(
initial
? parseCondition(initial)
: makeLogicNode('and', [])
);
const scope = $derived<ScopeEntry[]>(buildScope(container));
const errors = $derived<ConditionError[]>(
validateCondition(root, scope)
);
const tautology = $derived(detectTautology(root));
const compiled = $derived<JsonLogicValue>(
errors.length === 0 ? compileCondition(root) : initial ?? true
);
const isValid = $derived(errors.length === 0);
// ── Mutaciones ────────────────────────────────────────────────────────────
function setRoot(node: ConditionNode) {
root = node;
}
/** Añade un CompareNode vacío como hijo del LogicNode con `parentId`. */
function addCompare(parentId: string) {
const child = makeCompareNode('==');
root = addChild(root, parentId, child);
}
/** Añade un LogicNode (and/or) como hijo del LogicNode con `parentId`. */
function addLogic(parentId: string, op: LogicOp = 'and') {
const child = makeLogicNode(op, []);
root = addChild(root, parentId, child);
}
/** Elimina el nodo con `id`. Si el árbol queda vacío, resetea a and vacío. */
function remove(id: string) {
const next = removeNode(root, id);
root = next ?? makeLogicNode('and', []);
}
/** Reemplaza el operador de un CompareNode. */
function setOperator(id: string, op: CompareOp) {
const current = findNode(root, id);
if (!current || current.kind !== 'compare') return;
root = replaceNode(root, id, { ...current, op });
}
/** Reemplaza el operando izquierdo de un CompareNode. */
function setLeft(id: string, operand: Operand) {
const current = findNode(root, id);
if (!current || current.kind !== 'compare') return;
root = replaceNode(root, id, { ...current, left: operand });
}
/** Reemplaza el operando derecho de un CompareNode. */
function setRight(id: string, operand: Operand | [Operand, Operand]) {
const current = findNode(root, id);
if (!current || current.kind !== 'compare') return;
root = replaceNode(root, id, { ...current, right: operand });
}
/** Cambia el operador lógico (and ↔ or) de un LogicNode. */
function setLogicOp(id: string, op: LogicOp) {
const current = findNode(root, id);
if (!current || current.kind !== 'logic') return;
root = replaceNode(root, id, { ...current, op });
}
/**
* Operadores válidos para el att referenciado por `varPath`.
* Útil para poblar el selector de operador en la UI.
*/
function validOperatorsFor(varPath: string): CompareOp[] {
const entry = scope.find(e => e.path === varPath);
if (!entry) return ['==', '!='];
return getValidOperators(entry.domain);
}
/**
* Carga una condición existente en el editor (p.ej. al abrir una regla).
*/
function load(condition: JsonLogicValue) {
root = parseCondition(condition);
}
/** Resetea el editor a un LogicNode and vacío. */
function reset() {
root = makeLogicNode('and', []);
}
// ── API pública ───────────────────────────────────────────────────────────
return {
// Estado (reactivo)
get root() { return root; },
get scope() { return scope; },
get errors() { return errors; },
get tautology() { return tautology; },
get compiled() { return compiled; },
get isValid() { return isValid; },
// Mutaciones
setRoot,
addCompare,
addLogic,
remove,
setOperator,
setLeft,
setRight,
setLogicOp,
validOperatorsFor,
load,
reset,
};
}
export type ConditionBuilderStore = ReturnType<typeof createConditionBuilderStore>;
// ─────────────────────────────────────────────────────────────────────────────
// INTERNAL HELPERS
// ─────────────────────────────────────────────────────────────────────────────
function findNode(root: ConditionNode, id: string): ConditionNode | null {
if (root.id === id) return root;
if (root.kind === 'logic') {
for (const child of root.children) {
const found = findNode(child, id);
if (found) return found;
}
}
return null;
}

@ -0,0 +1,426 @@
import { describe, it, expect } from 'vitest';
import {asJsonCondition, type JsonLogicValue} from "@/jslg";
import {
buildScope,
getValidOperators,
isOperatorValid,
detectTautology,
compileCondition,
parseCondition,
validateCondition,
makeLogicNode,
makeCompareNode,
replaceNode,
removeNode,
addChild,
} from '../lib/condition_editor';
import type { Att, Sec, Obj } from '@/vcen/lib/types';
import type { ScopeEntry } from '../lib/condition_editor';
// ─────────────────────────────────────────────────────────────────────────────
// FIXTURES
// ─────────────────────────────────────────────────────────────────────────────
function makeAtt(id: string, kind: string = 'string'): Att {
return {
id: id as Att['id'],
name: id,
value: { domain: { kind } as any, value: '' },
};
}
function makeObj(id: string, atts: Att[], secs: Sec[] = []): Obj {
return { id: id as Obj['id'], name: id, atts, secs };
}
function makeSec(id: string, atts: Att[]): Sec {
return { id: id as Sec['id'], name: id, atts };
}
function varOp(path: string) { return { kind: 'var' as const, path }; }
function litOp(value: unknown) { return { kind: 'literal' as const, value }; }
// ─────────────────────────────────────────────────────────────────────────────
// buildScope
// ─────────────────────────────────────────────────────────────────────────────
describe('buildScope', () => {
it('incluye atts directos del Obj', () => {
const obj = makeObj('ob_bmw', [makeAtt('at_color'), makeAtt('at_acabado')]);
const scope = buildScope(obj);
expect(scope.map(e => e.path)).toContain('at_color');
expect(scope.map(e => e.path)).toContain('at_acabado');
});
it('incluye atts de secciones hijas con path relativo', () => {
const sec = makeSec('sc_motor', [makeAtt('at_fuel'), makeAtt('at_cv')]);
const obj = makeObj('ob_bmw', [], [sec]);
const scope = buildScope(obj);
expect(scope.map(e => e.path)).toContain('sc_motor/at_fuel');
expect(scope.map(e => e.path)).toContain('sc_motor/at_cv');
});
it('incluye tanto atts directos como de secciones', () => {
const sec = makeSec('sc_motor', [makeAtt('at_fuel')]);
const obj = makeObj('ob_bmw', [makeAtt('at_color')], [sec]);
const scope = buildScope(obj);
expect(scope).toHaveLength(2);
});
it('scope de Sec solo incluye sus atts directos', () => {
const sec = makeSec('sc_motor', [makeAtt('at_fuel'), makeAtt('at_cv')]);
const scope = buildScope(sec);
expect(scope.map(e => e.path)).toContain('at_fuel');
expect(scope.map(e => e.path)).toContain('at_cv');
expect(scope).toHaveLength(2);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// getValidOperators / isOperatorValid
// ─────────────────────────────────────────────────────────────────────────────
describe('getValidOperators', () => {
it('string → solo == y !=', () => {
const ops = getValidOperators({ kind: 'string' } as any);
expect(ops).toContain('==');
expect(ops).toContain('!=');
expect(ops).not.toContain('>');
expect(ops).not.toContain('date-before');
});
it('number → incluye operadores de comparación numérica', () => {
const ops = getValidOperators({ kind: 'number' } as any);
expect(ops).toContain('>');
expect(ops).toContain('<=');
expect(ops).not.toContain('date-before');
});
it('date → incluye operadores de fecha', () => {
const ops = getValidOperators({ kind: 'date' } as any);
expect(ops).toContain('date-before');
expect(ops).toContain('date-after');
expect(ops).toContain('date-between');
expect(ops).not.toContain('>');
});
it('boolean → solo == y !=', () => {
const ops = getValidOperators({ kind: 'boolean' } as any);
expect(ops).toEqual(['==', '!=']);
});
});
describe('isOperatorValid', () => {
it('> es válido para number', () => {
expect(isOperatorValid('>', { kind: 'number' } as any)).toBe(true);
});
it('> no es válido para string', () => {
expect(isOperatorValid('>', { kind: 'string' } as any)).toBe(false);
});
it('date-before es válido para date', () => {
expect(isOperatorValid('date-before', { kind: 'date' } as any)).toBe(true);
});
it('date-before no es válido para number', () => {
expect(isOperatorValid('date-before', { kind: 'number' } as any)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// detectTautology
// ─────────────────────────────────────────────────────────────────────────────
describe('detectTautology', () => {
it('unknown cuando el left es var', () => {
const node = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
expect(detectTautology(node).kind).toBe('unknown');
});
it('always-true cuando 1 == 1', () => {
const node = makeCompareNode('==', litOp(1), litOp(1));
expect(detectTautology(node).kind).toBe('always-true');
});
it('always-false cuando 1 == 2', () => {
const node = makeCompareNode('==', litOp(1), litOp(2));
expect(detectTautology(node).kind).toBe('always-false');
});
it('always-true cuando 5 > 3', () => {
const node = makeCompareNode('>', litOp(5), litOp(3));
expect(detectTautology(node).kind).toBe('always-true');
});
it('always-false cuando 2 > 3', () => {
const node = makeCompareNode('>', litOp(2), litOp(3));
expect(detectTautology(node).kind).toBe('always-false');
});
it('and: always-false si algún hijo es always-false', () => {
const node = makeLogicNode('and', [
makeCompareNode('==', litOp(1), litOp(1)), // true
makeCompareNode('==', litOp(1), litOp(2)), // false
]);
expect(detectTautology(node).kind).toBe('always-false');
});
it('and: always-true si todos los hijos son always-true', () => {
const node = makeLogicNode('and', [
makeCompareNode('==', litOp(1), litOp(1)),
makeCompareNode('!=', litOp(1), litOp(2)),
]);
expect(detectTautology(node).kind).toBe('always-true');
});
it('or: always-true si algún hijo es always-true', () => {
const node = makeLogicNode('or', [
makeCompareNode('==', litOp(1), litOp(2)), // false
makeCompareNode('==', litOp(1), litOp(1)), // true
]);
expect(detectTautology(node).kind).toBe('always-true');
});
it('or: always-false si todos los hijos son always-false', () => {
const node = makeLogicNode('or', [
makeCompareNode('==', litOp(1), litOp(2)),
makeCompareNode('==', litOp(3), litOp(4)),
]);
expect(detectTautology(node).kind).toBe('always-false');
});
it('unknown si algún hijo es unknown en and', () => {
const node = makeLogicNode('and', [
makeCompareNode('==', varOp('at_color'), litOp('rojo')),
makeCompareNode('==', litOp(1), litOp(1)),
]);
expect(detectTautology(node).kind).toBe('unknown');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// compileCondition
// ─────────────────────────────────────────────────────────────────────────────
describe('compileCondition', () => {
it('compila CompareNode con var y literal', () => {
const node = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
const result = compileCondition(node);
expect(result).toEqual({ '==': [{ var: 'at_color' }, 'rojo'] });
});
it('compila LogicNode and con dos hijos', () => {
const node = makeLogicNode('and', [
makeCompareNode('==', varOp('at_color'), litOp('rojo')),
makeCompareNode('!=', varOp('at_acabado'), litOp('lujo')),
]);
expect(compileCondition(node)).toEqual({
and: [
{ '==': [{ var: 'at_color' }, 'rojo'] },
{ '!=': [{ var: 'at_acabado' }, 'lujo'] },
],
});
});
it('compila date-between con dos bounds', () => {
const node = makeCompareNode('date-between', varOp('at_fecha'), [litOp('2024-01-01'), litOp('2024-12-31')]);
expect(compileCondition(node)).toEqual({
'date-between': [{ var: 'at_fecha' }, '2024-01-01', '2024-12-31'],
});
});
it('compila LogicNode or anidado', () => {
const node = makeLogicNode('or', [
makeCompareNode('==', varOp('at_color'), litOp('rojo')),
makeCompareNode('==', varOp('at_color'), litOp('azul')),
]);
expect(compileCondition(node)).toEqual({
or: [
{ '==': [{ var: 'at_color' }, 'rojo'] },
{ '==': [{ var: 'at_color' }, 'azul'] },
],
});
});
});
// ─────────────────────────────────────────────────────────────────────────────
// parseCondition
// ─────────────────────────────────────────────────────────────────────────────
describe('parseCondition', () => {
it('parsea { "==": [{ var: "at_color" }, "rojo"] }', () => {
const node = parseCondition({ '==': [{ var: 'at_color' }, 'rojo'] });
expect(node.kind).toBe('compare');
if (node.kind === 'compare') {
expect(node.op).toBe('==');
expect(node.left).toEqual({ kind: 'var', path: 'at_color' });
expect(node.right).toEqual({ kind: 'literal', value: 'rojo' });
}
});
it('parsea { and: [...] }', () => {
const node = parseCondition({
and: [
{ '==': [{ var: 'at_color' }, 'rojo'] },
{ '!=': [{ var: 'at_acabado' }, 'lujo'] },
],
});
expect(node.kind).toBe('logic');
if (node.kind === 'logic') {
expect(node.op).toBe('and');
expect(node.children).toHaveLength(2);
}
});
it('compile(parse(x)) === x para condición simple', () => {
const original = { '==': [{ var: 'at_color' }, 'rojo'] };
const node = parseCondition(original);
expect(compileCondition(node)).toEqual(original);
});
it('compile(parse(x)) === x para condición compleja', () => {
const original = asJsonCondition({
and: [
{ '==': [{ var: 'at_color' }, 'rojo'] },
{ '!=': [{ var: 'at_acabado' }, 'lujo'] },
],
});
expect(compileCondition(parseCondition(original))).toEqual(original);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// validateCondition
// ─────────────────────────────────────────────────────────────────────────────
describe('validateCondition', () => {
function makeScope(...atts: [string, string][]): ScopeEntry[] {
return atts.map(([path, kind]) => ({
path,
att: makeAtt(path.split('/').pop()!),
domain: { kind } as any,
}));
}
it('sin errores cuando la condición es válida', () => {
const scope = makeScope(['at_color', 'string']);
const node = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
expect(validateCondition(node, scope)).toHaveLength(0);
});
it('error cuando la variable no está en el scope', () => {
const scope = makeScope(['at_color', 'string']);
const node = makeCompareNode('==', varOp('at_desconocido'), litOp('rojo'));
const errors = validateCondition(node, scope);
expect(errors).toHaveLength(1);
expect(errors[0].message).toContain('at_desconocido');
});
it('error cuando el operador no es válido para el dominio', () => {
const scope = makeScope(['at_color', 'string']);
const node = makeCompareNode('>', varOp('at_color'), litOp('rojo'));
const errors = validateCondition(node, scope);
expect(errors).toHaveLength(1);
expect(errors[0].message).toContain('>');
});
it('error cuando LogicNode no tiene hijos', () => {
const scope = makeScope(['at_color', 'string']);
const node = makeLogicNode('and', []);
const errors = validateCondition(node, scope);
expect(errors).toHaveLength(1);
expect(errors[0].message).toContain('and');
});
it('valida recursivamente hijos de LogicNode', () => {
const scope = makeScope(['at_color', 'string']);
const node = makeLogicNode('and', [
makeCompareNode('==', varOp('at_color'), litOp('rojo')),
makeCompareNode('==', varOp('at_desconocido'), litOp('x')),
]);
const errors = validateCondition(node, scope);
expect(errors).toHaveLength(1);
expect(errors[0].message).toContain('at_desconocido');
});
it('sin errores con path de sección sc_motor/at_fuel', () => {
const scope = makeScope(['sc_motor/at_fuel', 'string']);
const node = makeCompareNode('==', varOp('sc_motor/at_fuel'), litOp('gasolina'));
expect(validateCondition(node, scope)).toHaveLength(0);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// replaceNode / removeNode / addChild
// ─────────────────────────────────────────────────────────────────────────────
describe('mutación del árbol', () => {
it('replaceNode reemplaza un nodo por id', () => {
const child1 = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
const child2 = makeCompareNode('!=', varOp('at_acabado'), litOp('lujo'));
const root = makeLogicNode('and', [child1, child2]);
const newChild = makeCompareNode('==', varOp('at_color'), litOp('azul'));
const result = replaceNode(root, child1.id, newChild);
expect(result.kind).toBe('logic');
if (result.kind === 'logic') {
expect(result.children[0]).toBe(newChild);
expect(result.children[1]).toBe(child2);
}
});
it('replaceNode no muta el árbol original', () => {
const child = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
const root = makeLogicNode('and', [child]);
const newNode = makeCompareNode('==', varOp('at_color'), litOp('azul'));
replaceNode(root, child.id, newNode);
expect(root.kind === 'logic' && root.children[0]).toBe(child);
});
it('removeNode elimina un nodo por id', () => {
const child1 = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
const child2 = makeCompareNode('!=', varOp('at_acabado'), litOp('lujo'));
const root = makeLogicNode('and', [child1, child2]);
const result = removeNode(root, child1.id);
expect(result?.kind).toBe('logic');
if (result?.kind === 'logic') {
expect(result.children).toHaveLength(1);
expect(result.children[0]).toBe(child2);
}
});
it('removeNode devuelve null si el LogicNode queda sin hijos', () => {
const child = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
const root = makeLogicNode('and', [child]);
expect(removeNode(root, child.id)).toBeNull();
});
it('removeNode devuelve null si se elimina el nodo raíz', () => {
const node = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
expect(removeNode(node, node.id)).toBeNull();
});
it('addChild añade un hijo a un LogicNode', () => {
const root = makeLogicNode('and', []);
const newChild = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
const result = addChild(root, root.id, newChild);
expect(result.kind).toBe('logic');
if (result.kind === 'logic') {
expect(result.children).toHaveLength(1);
expect(result.children[0]).toBe(newChild);
}
});
it('addChild no muta el árbol original', () => {
const root = makeLogicNode('and', []);
const newChild = makeCompareNode('==', varOp('at_color'), litOp('rojo'));
addChild(root, root.id, newChild);
expect(root.children).toHaveLength(0);
});
});

@ -1,6 +1,5 @@
import type {
Cat, Obj, Sec, Att,
RuleActionAttribute,
RuleActionDomain,
RuleActionValue,
RuleActionValidation,
@ -9,14 +8,17 @@ import type {
AttEffectiveState,
EffectiveState,
EvaluationResult,
RuleID,
RuleID, OptID, AttOptionDomain, Opt, AttValueOption, AttState,
} from './types';
import { ATT_STATE_PRECEDENCE } from './types';
import {validateAttValue} from "@/vcen/validation.ts";
import {joinPath, nodeSeparator, serializeRelativePath} from "@/vcen/node_util.ts";
import {validateAttValue} from "@/vcen/lib/validation.ts";
import { serializeRelativePath, joinPath, keyToAttPath} from "@/vcen/lib/node_util.ts";
import {JsonLogicEngine} from "@/jslg";
// ─────────────────────────────────────────────────────────────────────────────
// CONSTANTS
// ─────────────────────────────────────────────────────────────────────────────
@ -28,7 +30,7 @@ const MAX_ITERATIONS = 20;
// ─────────────────────────────────────────────────────────────────────────────
/** Resuelve la instancia de JsonLogicEngine a usar */
function resolveEngine(engine: JsonLogicEngine | null): JsonLogicEngine {
export function resolveEngine(engine: JsonLogicEngine | null): JsonLogicEngine {
return engine ?? new JsonLogicEngine();
}
@ -58,7 +60,7 @@ interface EvalContext {
* Construye el estado plano `ConfigState` a partir de un `Cat`.
* Recorre toda la jerarquía y extrae los valores actuales de cada `Att`.
*/
function buildConfigState(cat: Cat): ConfigState {
export function buildConfigState(cat: Cat): ConfigState {
const state: ConfigState = {};
const addAtt = (prefix: string, att: Att) => {
@ -123,10 +125,10 @@ function buildJsonLogicData(ctx: EvalContext): Record<string, unknown> {
current[parts[parts.length - 1]] = value;
}
const prefixParts = ctx.prefix.split(nodeSeparator).filter(Boolean);
const prefixParts = keyToAttPath(ctx.prefix).filter(Boolean);
for (const [key, value] of Object.entries(ctx.state)) {
const parts = key.split(nodeSeparator).filter(Boolean);
const parts = keyToAttPath(key).filter(Boolean);
// Path absoluto anidado: data["ct:cat"]["ob:bmw"]["at:color"]
setNested(data, parts, value);
@ -158,15 +160,15 @@ function defaultEffectiveState(): AttEffectiveState {
}
function mergeAttributeAction(
current: AttEffectiveState,
action: RuleActionAttribute,
current: AttEffectiveState,
resolvedState: AttState,
): AttEffectiveState {
const currentPrecedence = ATT_STATE_PRECEDENCE[current.state];
const newPrecedence = ATT_STATE_PRECEDENCE[action.state];
const newPrecedence = ATT_STATE_PRECEDENCE[resolvedState];
return {
...current,
state: newPrecedence > currentPrecedence ? action.state : current.state,
state: newPrecedence > currentPrecedence ? resolvedState : current.state,
};
}
@ -237,10 +239,11 @@ function applyRuleSet(
for (const rule of sorted) {
const conditionMet = engine.evaluate(rule.condition, data) as boolean;
if (!conditionMet) continue;
for (const action of rule.actions) {
if (action.type === 'validation') {
// validation: apply when condition IS met
if (!conditionMet) continue;
const current = effectiveState.get(selfPath) ?? defaultEffectiveState();
effectiveState.set(selfPath, mergeValidationAction(current, action, rule.id));
continue;
@ -255,16 +258,19 @@ function applyRuleSet(
switch (action.type) {
case 'attribute': {
effectiveState.set(targetPath, mergeAttributeAction(current, action));
const resolvedState = conditionMet ? action.ifTrue : action.ifFalse;
effectiveState.set(targetPath, mergeAttributeAction(current, resolvedState));
break;
}
case 'domain': {
if (!conditionMet) break;
effectiveState.set(targetPath, mergeDomainAction(current, action));
break;
}
case 'value': {
if (!conditionMet) break;
const stored = valuePriorities.get(targetPath);
const { state, stored: newStored } = mergeValueAction(
current, action, rule.id, rule.priority, stored,
@ -299,9 +305,113 @@ function snapshotState(effectiveState: EffectiveState): string {
* @param att - Atributo a evaluar
* @param prefix - Path del contenedor del atributo (Object o Section)
* @param state - Estado plano del configurador
* @param engine
* @returns Estado efectivo del atributo
*/
// ─────────────────────────────────────────────────────────────────────────────
// OPTION EVALUATION
// ─────────────────────────────────────────────────────────────────────────────
/**
* Evalúa qué options de un dominio `option` mode `list` son válidas
* en el contexto actual del Obj que contiene el Att.
*
* Para cada AttOpt:
* 1. Evalúa las rules globales de la Opt (del Cat.opts)
* 2. Evalúa las rules locales del AttOpt (si existen) — AND con las globales
* 3. Si alguna falla → opción inválida (forbiddenValue)
*
* Retorna:
* - `validOptIds` — OptIDs disponibles en orden original
* - `invalidOptIds` — OptIDs que no cumplen sus rules
*/
function evaluateOptions(
domain: AttOptionDomain & { mode: 'list' },
cats: Opt[],
ctx: EvalContext,
engine: JsonLogicEngine,
): { validOptIds: OptID[]; invalidOptIds: OptID[] } {
const validOptIds: OptID[] = [];
const invalidOptIds: OptID[] = [];
for (const attOpt of domain.list) {
const globalOpt = cats.find(o => o.id === attOpt.optId);
// Evaluar rules globales de la Opt
const globalValid = globalOpt?.rules
? isRuleSetMet(globalOpt.rules, ctx, engine)
: true;
// Evaluar rules locales del AttOpt (solo si las globales pasan)
const localValid = globalValid && attOpt.rules
? isRuleSetMet(attOpt.rules, ctx, engine)
: globalValid;
if (localValid) {
validOptIds.push(attOpt.optId);
} else {
invalidOptIds.push(attOpt.optId);
}
}
return { validOptIds, invalidOptIds };
}
/**
* Devuelve true si TODAS las rules del RuleSet se cumplen
* (todas las condiciones son true).
*/
function isRuleSetMet(
ruleSet: RuleSet,
ctx: EvalContext,
engine: JsonLogicEngine,
): boolean {
const data = buildJsonLogicData(ctx);
return ruleSet.rules.every(rule =>
engine.evaluate(rule.condition, data) as boolean
);
}
/**
* Aplica la lógica de options a un AttEffectiveState:
* - Marca las invalidOptIds como forbiddenValues
* - Si el valor actual es inválido → derivedValue con la siguiente opción válida
* - Si todas son inválidas → state = 'forbidden'
* - Si solo una válida → derivedValue con esa opción
*/
function applyOptionLogic(
attValue: AttValueOption,
validIds: OptID[],
invalidIds: OptID[],
current: AttEffectiveState,
): AttEffectiveState {
let state = { ...current };
// Añadir forbiddenValues
for (const id of invalidIds) {
if (!state.forbiddenValues.includes(id)) {
state = { ...state, forbiddenValues: [...state.forbiddenValues, id] };
}
}
// Si todas inválidas → forbidden
if (validIds.length === 0) {
return { ...state, state: 'forbidden' };
}
// Valor actual (normalizado a escalar para comparar)
const currentVal = Array.isArray(attValue.value) ? attValue.value[0] : attValue.value;
const currentIsInvalid = invalidIds.includes(currentVal as OptID);
if (currentIsInvalid || (validIds.length === 1 && currentVal !== validIds[0])) {
const nextValid = validIds[0];
const derived: AttValueOption = { domain: attValue.domain, value: nextValid };
state = { ...state, derivedValue: derived };
}
return state;
}
export function evaluateAtt(
att: Att,
prefix: string,
@ -369,6 +479,7 @@ export function evaluateSec(
* @param prefix - Path del Catalog padre
* @param state - Estado plano del configurador
* @param engine
* @param opts
* @returns EffectiveState completo del objeto
*/
export function evaluateObj(
@ -376,6 +487,7 @@ export function evaluateObj(
prefix: string,
state: ConfigState,
engine: JsonLogicEngine | null = null,
opts: Opt[] = [],
): EffectiveState {
const resolvedEngine = resolveEngine(engine);
const objPath = joinPath(prefix, obj.id);
@ -389,13 +501,23 @@ export function evaluateObj(
for (const att of obj.atts) {
const attPath = joinPath(objPath, att.id);
const ctx: EvalContext = { state, prefix: objPath };
if (att.rules) {
const ctx: EvalContext = { state, prefix: objPath };
applyRuleSet(att.rules, ctx, effectiveState, attPath, objPath, valuePriorities, resolvedEngine);
}
if (!effectiveState.has(attPath)) {
effectiveState.set(attPath, defaultEffectiveState());
}
// Evaluar options si el dominio es option/list
if (att.value.domain.kind === 'option' && att.value.domain.mode === 'list') {
const { validOptIds, invalidOptIds } = evaluateOptions(
att.value.domain as AttOptionDomain & { mode: 'list' }, opts, ctx, resolvedEngine,
);
const current = effectiveState.get(attPath) ?? defaultEffectiveState();
effectiveState.set(attPath, applyOptionLogic(
att.value as AttValueOption, validOptIds, invalidOptIds, current,
));
}
}
for (const sec of obj.secs) {
@ -408,13 +530,23 @@ export function evaluateObj(
for (const att of sec.atts) {
const attPath = joinPath(secPath, att.id);
const ctx: EvalContext = { state, prefix: secPath };
if (att.rules) {
const ctx: EvalContext = { state, prefix: secPath };
applyRuleSet(att.rules, ctx, effectiveState, attPath, secPath, valuePriorities, resolvedEngine);
}
if (!effectiveState.has(attPath)) {
effectiveState.set(attPath, defaultEffectiveState());
}
// Evaluar options si el dominio es option/list
if (att.value.domain.kind === 'option' && att.value.domain.mode === 'list') {
const { validOptIds, invalidOptIds } = evaluateOptions(
att.value.domain as AttOptionDomain & { mode: 'list' }, opts, ctx, resolvedEngine,
);
const current = effectiveState.get(attPath) ?? defaultEffectiveState();
effectiveState.set(attPath, applyOptionLogic(
att.value as AttValueOption, validOptIds, invalidOptIds, current,
));
}
}
}
@ -440,12 +572,10 @@ export function evaluateObj(
* @param engine
* @returns Resultado de la evaluación con estado efectivo, errores y warnings
*/
export function evaluateCat(
cat: Cat,
engine: JsonLogicEngine | null = null
): EvaluationResult {
export function evaluateCat(cat: Cat, engine: JsonLogicEngine | null = null): EvaluationResult {
const resolvedEngine = resolveEngine(engine);
let state = buildConfigState(cat);
const baseState = buildConfigState(cat); // inmutable — inputs del usuario
let state = { ...baseState };
let effectiveState = new Map<string, AttEffectiveState>();
let prevSnapshot = '';
let iterations = 0;
@ -456,7 +586,7 @@ export function evaluateCat(
const nextEffective = new Map<string, AttEffectiveState>();
for (const obj of cat.objs) {
const objResult = evaluateObj(obj, cat.id, state, resolvedEngine);
const objResult = evaluateObj(obj, cat.id, state, resolvedEngine, cat.opts ?? []);
for (const [k, v] of objResult) {
nextEffective.set(k, v);
}
@ -470,8 +600,8 @@ export function evaluateCat(
if (stable) break; // sin cambios → convergió
// Propagar derivedValues al estado para el siguiente ciclo
const nextState = { ...state };
// Propagar derivedValues: partir de baseState para evitar sticky state
const nextState = { ...baseState };
for (const [path, attState] of effectiveState) {
if (attState.derivedValue !== undefined) {
nextState[path] = attState.derivedValue.value;
@ -560,4 +690,4 @@ function collectAtts(obj: Obj, catId: string): Array<{ path: string; att: Att }>
}
return result;
}
}

@ -3,9 +3,9 @@ import {
serializeAttPath,
serializeRelativePath,
joinPath, keyToAttPath,
} from './node_util';
} from './node_util.ts';
import { JsonLogicEngine } from "@/jslg";
import type { DependencyGraph} from "@/vcen/types/graph.ts";
import type { DependencyGraph} from "@/vcen/lib/types/graph.ts";

@ -5,13 +5,12 @@ import type {
DependencyGraph,
EvaluationResult, AttChange
} from './types';
import { validateAttValue } from './validation';
import { validateAttValue } from './validation.ts';
import {
serializeAttPath,
buildConfigState
} from './node_util';
import { evaluateObj } from './engine';
import { getTransitiveAffected } from './graph';
keyToAttPath, NODE_SEPARATOR, serializeAttPath
} from './node_util.ts';
import {buildConfigState, evaluateObj} from './engine.ts';
import { getTransitiveAffected } from './graph.ts';
// ─────────────────────────────────────────────────────────────────────────────
@ -63,14 +62,17 @@ export function evaluateIncremental(
// ── 3. Identificar Objs que contienen atts afectados ─────────────────────
const affectedObjKeys = new Set<string>();
for (const key of affectedKeys) {
const parts = key.split('/');
const parts = keyToAttPath(key);
// key = ct:cat/ob:obj/... → objKey = ct:cat/ob:obj
if (parts.length >= 2) {
affectedObjKeys.add(`${parts[0]}/${parts[1]}`);
affectedObjKeys.add(`${parts[0]}${NODE_SEPARATOR}${parts[1]}`);
}
}
// ── 4. Re-evaluar los Objs afectados con forward chaining parcial ────────
const baseStateForIncremental = buildConfigState(cat);
baseStateForIncremental[changedKey] = change.value;
const nextEffective = new Map(prev.effectiveState);
let currentState = { ...state };
let prevSnapshot = '';
@ -79,7 +81,7 @@ export function evaluateIncremental(
const iterEffective = new Map(nextEffective);
for (const obj of cat.objs) {
const objKey = `${cat.id}/${obj.id}`;
const objKey = `${cat.id}${NODE_SEPARATOR}${obj.id}`;
if (!affectedObjKeys.has(objKey)) continue;
const objResult = evaluateObj(obj, cat.id, currentState, resolvedEngine);
@ -88,8 +90,8 @@ export function evaluateIncremental(
}
}
// Propagar derivedValues de los atts afectados al estado
const nextState = { ...currentState };
// Partir de baseState para evitar sticky state en el forward chaining parcial
const nextState = { ...baseStateForIncremental };
for (const key of affectedKeys) {
const es = iterEffective.get(key);
if (es?.derivedValue !== undefined) {
@ -113,7 +115,7 @@ export function evaluateIncremental(
const warnings = new Set(prev.warnings.filter(p => !affectedKeys.has(p)));
for (const key of affectedKeys) {
const parts = key.split('/');
const parts = keyToAttPath(key);
if (parts.length < 2) continue;
const att = findAtt(cat, key);
@ -166,9 +168,12 @@ export function evaluateIncremental(
* Incorpora los derivedValues para que el estado refleje la realidad actual.
*/
function buildStateFromResult(cat: Cat, prev: EvaluationResult): Record<string, unknown> {
const state = buildConfigState(cat);
// Partir siempre del baseState (valores originales del Cat) para evitar sticky state.
// Los derivedValues del resultado previo se propagan encima, pero si una regla
// deja de cumplirse en la siguiente iteración, el valor vuelve al base.
const baseState = buildConfigState(cat);
const state = { ...baseState };
// Sobreescribir con derivedValues del resultado previo
for (const [path, es] of prev.effectiveState) {
if (es.derivedValue !== undefined) {
state[path] = es.derivedValue.value;
@ -182,7 +187,8 @@ function buildStateFromResult(cat: Cat, prev: EvaluationResult): Record<string,
* Encuentra un `Att` por su path serializado dentro del `Cat`.
*/
function findAtt(cat: Cat, key: string): Att | undefined {
const parts = key.split('/');
/*
const parts = keyToAttPath(key);
if (parts.length === 2) {
// ct:cat/at:x
@ -191,16 +197,48 @@ function findAtt(cat: Cat, key: string): Att | undefined {
if (parts.length === 3) {
// ct:cat/ob:obj/at:x
const obj = cat.objs.find(o => `${cat.id}/${o.id}` === `${parts[0]}/${parts[1]}`);
const obj = cat.objs.find(o => `${cat.id}${NODE_SEPARATOR}${o.id}` === `${parts[0]}${NODE_SEPARATOR}${parts[1]}`);
return obj?.atts.find(a => a.id === parts[2]);
}
if (parts.length === 4) {
// ct_cat/ob_obj/sc_sec/at_x
// ct:cat/ob:obj/sc:sec/at:x
const obj = cat.objs.find(o => o.id === parts[1]);
const sec = obj?.secs.find(s => s.id === parts[2]);
return sec?.atts.find(a => a.id === parts[3]);
}
return undefined;
}
*/
const parts = key.split(NODE_SEPARATOR);
// Caso 1: Atributo de Cat [CatID, AttID]
if (parts.length === 2) {
return cat.atts.find(a => a.id === parts[1]);
}
// Caso 2: Atributo de Obj [CatID, ObjID, AttID]
if (parts.length === 3) {
const [pCatId, pObjId, pAttId] = parts;
// Verificamos que el CatID coincida y buscamos el objeto
if (cat.id !== pCatId) return undefined;
const obj = cat.objs.find(o => o.id === pObjId);
return obj?.atts.find(a => a.id === pAttId);
}
// Caso 3: Atributo de Section [CatID, ObjID, SecID, AttID]
if (parts.length === 4) {
const [pCatId, pObjId, pSecId, pAttId] = parts;
if (cat.id !== pCatId) return undefined;
const obj = cat.objs.find(o => o.id === pObjId);
const sec = obj?.secs.find(s => s.id === pSecId);
return sec?.atts.find(a => a.id === pAttId);
}
return undefined;
}

@ -1,4 +1,4 @@
import {type RelativePath, type RuleID, ruleIDPrefix} from "@/vcen/types/rule.ts";
import {type RelativePath, type RuleID, ruleIDPrefix} from "@/vcen/lib/types/rule.ts";
import {JSON_LOGIC_PATH_SEPARATOR} from "@/jslg";
import {
type Att,
@ -10,14 +10,14 @@ import {
catIDPrefix,
type ObjID, objIDPrefix, type OptID, optIDPrefix,
type SecID, secIDPrefix
} from "@/vcen/types";
} from "@/vcen/lib/types";
// ─────────────────────────────────────────────────────────────────────────────
// IDS HELPERS
// ─────────────────────────────────────────────────────────────────────────────
export function toCatID (catId : string) : CatID { return `${catIDPrefix}_${catId}` as CatID; }
export function qtoCatID (catId : string) : CatID { return `${catIDPrefix}_${catId}` as CatID; }
export function toObjID (objId : string) : ObjID { return `${objIDPrefix}_${objId}` as ObjID; }
export function toSecID (secId : string) : SecID { return `${secIDPrefix}_${secId}` as SecID; }
export function toAttID (attId : string) : AttID { return `${attIDPrefix}_${attId}` as AttID; }
@ -32,11 +32,11 @@ export function toRuleID(ruleId: string) : RuleID { return `${ruleIDPrefix}_${ru
// ─────────────────────────────────────────────────────────────────────────────
export const nodeSeparator = JSON_LOGIC_PATH_SEPARATOR;
export const NODE_SEPARATOR = JSON_LOGIC_PATH_SEPARATOR;
export function parseAttPath(key: string): AttPath {
const parts = key.split(nodeSeparator);
const parts = key.split(NODE_SEPARATOR);
if (parts.length === 2) return parts as [CatID, AttID];
if (parts.length === 3) return parts as [CatID, ObjID, AttID];
if (parts.length === 4) return parts as [CatID, ObjID, SecID, AttID];
@ -45,12 +45,12 @@ export function parseAttPath(key: string): AttPath {
export function keyToAttPath(key: AttKey): AttPath {
return key.split(nodeSeparator) as unknown as AttPath;
return key.split(NODE_SEPARATOR) as unknown as AttPath;
}
export function serializeAttPath(path: AttPath): string {
return path.join(nodeSeparator);
return path.join(NODE_SEPARATOR);
}
@ -58,11 +58,11 @@ export function serializeAttPath(path: AttPath): string {
/** Serializa un RelativePath a string para usar en `{ "var": "..." }` */
export function serializeRelativePath(path: RelativePath): string {
return path.join(nodeSeparator);
return path.join(NODE_SEPARATOR);
}
export function joinPath(...parts: string[]): string {
return parts.filter(Boolean).join(nodeSeparator);
return parts.filter(Boolean).join(NODE_SEPARATOR);
}
export function resolveRelativePath(prefix: string, path: RelativePath): string {
@ -71,33 +71,3 @@ export function resolveRelativePath(prefix: string, path: RelativePath): string
/**
* Construye el estado plano `ConfigState` a partir de un `Cat`.
* Key = path serializado del att, Value = valor actual.
*/
export function buildConfigState(cat: Cat): Record<string, unknown> {
const state: Record<string, unknown> = {};
const addAtt = (prefix: string, att: Att) => {
state[joinPath(prefix, att.id)] = att.value.value;
};
for (const att of cat.atts) {
addAtt(cat.id, att);
}
for (const obj of cat.objs) {
const objPrefix = joinPath(cat.id, obj.id);
for (const att of obj.atts) {
addAtt(objPrefix, att);
}
for (const sec of obj.secs) {
const secPrefix = joinPath(objPrefix, sec.id);
for (const att of sec.atts) {
addAtt(secPrefix, att);
}
}
}
return state;
}

@ -6,11 +6,11 @@ import type {
AttPath,
DependencyGraph
} from './types';
import { serializeAttPath } from './node_util';
import { buildDependencyGraph } from './graph';
import { evaluateCat } from './engine';
import { evaluateIncremental } from './incremental';
import type {SessionListener} from "@/vcen/types/session.ts";
import { serializeAttPath } from './node_util.ts';
import { buildDependencyGraph } from './graph.ts';
import { evaluateCat } from './engine.ts';
import { evaluateIncremental } from './incremental.ts';
import type {SessionListener} from "@/vcen/lib/types/session.ts";

@ -1,6 +1,21 @@
import type {AttID, CatID, ObjID, OptID, SecID} from "@/vcen/types/node.ts";
import type {ISODate, ISODatetime, ISOTime} from "@/tsat/types";
import type {RuleSet} from "@/vcen/types/rule.ts";
import type {ID, ISODate, ISODatetime, ISOTime} from "@/tsat/types";
import type {RuleSet} from "@/vcen/lib/types/rule.ts";
import type {LingString} from "@/ling";
import type {Metadata} from "@/vcen/lib/types/metadata.ts";
import type {CatID} from "@/vcen/lib/types/cat.ts";
import type {ObjID} from "@/vcen/lib/types/obj.ts";
import type {SecID} from "@/vcen/lib/types/sec.ts";
import type {OptID} from "@/vcen/lib/types/opt.ts";
// ─────────────────────────────────────────────────────────────────────────────
// IDENTITY
// ─────────────────────────────────────────────────────────────────────────────
export const attIDPrefix = 'at' as const;
export type AttID = ID<typeof attIDPrefix>;
/**
* Path a un Attribute (siempre hoja).
@ -39,6 +54,30 @@ export interface AttMultiple {
}
/**
* Referencia local a una Option dentro del dominio de un Att.
*
* Permite sobreescribir `label` y `metadata` respecto a la definición
* global de la `Opt`. El `value` y las `rules` globales se heredan siempre.
*
* Las `rules` locales (si existen) se evalúan en AND con las globales:
* la opción es válida solo si ambas se cumplen.
*
* @example
* {
* optId: 'op:rojo',
* label: { es: 'Rojo deportivo' }, // sobreescribe label global
* rules: { rules: [ ruleOnlyConvertible ] } // regla local adicional
* }
*/
export interface AttOpt {
optId: OptID;
label?: LingString;
metadata?: Metadata;
rules?: RuleSet;
}
// ─────────────────────────────────────────────────────────────────────────────
// DOMAINS
// ─────────────────────────────────────────────────────────────────────────────
@ -99,14 +138,17 @@ export type AttBooleanDomain =
* Dominio para atributos de tipo opción.
* El valor referencia uno o varios OptID definidos en el catálogo.
*
* - `list` → el valor debe ser uno de los OptID de la lista
* - `list` → el valor debe ser una de las AttOpt de la lista.
* Cada AttOpt referencia una Opt global y puede sobreescribir
* label/metadata y añadir rules adicionales (AND con las globales).
* - `fixed` → constante del sistema, usada por el motor de reglas
* - `input` → el usuario selecciona la opción libremente
* - `input` → el usuario selecciona la opción libremente de todas las Opt del Cat
*/
export type AttOptionDomain =
| { kind: 'option'; mode: 'list'; list: OptID[]; multiple?: AttMultiple }
| { kind: 'option'; mode: 'fixed'; fixed: OptID }
| { kind: 'option'; mode: 'input'; multiple?: AttMultiple };
| { kind: 'option'; mode: 'list'; list: AttOpt[]; multiple?: AttMultiple }
| { kind: 'option'; mode: 'fixed'; fixed: OptID };
/**
* Dominio para atributos de tipo fecha.
@ -157,14 +199,18 @@ export type AttDomain =
// VALUES
// ─────────────────────────────────────────────────────────────────────────────
export interface AttValueString { domain: AttStringDomain; value: string | string[] }
export interface AttValueNumber { domain: AttNumberDomain; value: number | number[] }
export interface AttValueCurrency { domain: AttCurrencyDomain; value: number | number[] }
export interface AttValueBoolean { domain: AttBooleanDomain; value: boolean }
export interface AttValueOption { domain: AttOptionDomain; value: OptID | OptID[] }
export interface AttValueDate { domain: AttDateOnlyDomain; value: ISODate | ISODate[] }
export interface AttValueTime { domain: AttTimeDomain; value: ISOTime | ISOTime[] }
export interface AttValueDatetime { domain: AttDatetimeDomain; value: ISODatetime | ISODatetime[] }
export interface AttValueDomain {
domain : AttDomain;
}
export interface AttValueString extends AttValueDomain { domain: AttStringDomain; value: string | string[] }
export interface AttValueNumber extends AttValueDomain { domain: AttNumberDomain; value: number | number[] }
export interface AttValueCurrency extends AttValueDomain { domain: AttCurrencyDomain; value: number | number[] }
export interface AttValueBoolean extends AttValueDomain { domain: AttBooleanDomain; value: boolean }
export interface AttValueOption extends AttValueDomain { domain: AttOptionDomain; value: OptID | OptID[] }
export interface AttValueDate extends AttValueDomain { domain: AttDateOnlyDomain; value: ISODate | ISODate[] }
export interface AttValueTime extends AttValueDomain { domain: AttTimeDomain; value: ISOTime | ISOTime[] }
export interface AttValueDatetime extends AttValueDomain { domain: AttDatetimeDomain; value: ISODatetime | ISODatetime[] }
/**
* Unión de todos los valores posibles de un atributo.

@ -2,9 +2,20 @@
// CATALOG
// ─────────────────────────────────────────────────────────────────────────────
import type {CatID} from "@/vcen/types/node.ts";
import type {Att} from "@/vcen/types/att.ts";
import type {Obj} from "@/vcen/types/obj.ts";
import type {ID} from "@/tsat";
import type {Att} from "@/vcen/lib/types/att.ts";
import type {Obj} from "@/vcen/lib/types/obj.ts";
import type {Opt, OptCategory} from "@/vcen/lib/types/opt.ts";
// ─────────────────────────────────────────────────────────────────────────────
// IDENTITY
// ─────────────────────────────────────────────────────────────────────────────
export const catIDPrefix = 'ct' as const ;
export type CatID = ID<typeof catIDPrefix>;
/**
* Catalog — raíz del configurador.
@ -27,9 +38,15 @@ export interface Cat {
id: CatID;
name: string;
/** Options globales del catálogo — referenciadas desde dominios de tipo option */
opts: Opt[];
/** Atributos globales del catálogo */
atts: Att[];
/** Categorías de options — para agrupar opciones en la UI */
optCategories: OptCategory[];
/** Objetos configurables del catálogo */
objs: Obj[];
}

@ -0,0 +1,14 @@
// engine.ts
// ─────────────────────────────────────────────────────────────────────────────
// 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>;

@ -1,6 +1,6 @@
import type {AttKey, AttPath} from "@/vcen/types/att.ts";
import type {Rule, RuleID} from "@/vcen/types/rule.ts";
import {serializeAttPath} from "@/vcen/node_util.ts";
import type {AttKey, AttPath} from "@/vcen/lib/types/att.ts";
import type {Rule, RuleID} from "@/vcen/lib/types/rule.ts";
import {serializeAttPath} from "@/vcen/lib/node_util.ts";
export type GraphNode =
| { kind: 'att'; path: AttPath }

@ -2,7 +2,7 @@
// TYPES
// ─────────────────────────────────────────────────────────────────────────────
import type {AttPath} from "@/vcen/types/att.ts";
import type {AttPath} from "@/vcen/lib/types/att.ts";
/**
* Un cambio de valor pendiente de aplicar.

@ -0,0 +1,16 @@
export * from './att.ts';
export * from './cat.ts';
export * from './graph.ts';
export * from './engine.ts';
export * from './incremental.ts';
export * from './metadata.ts';
export * from './node.ts';
export * from './obj.ts';
export * from './opt.ts';
export * from './rule.ts';
export * from './sec.ts';
export * from './validation.ts'

@ -0,0 +1,8 @@
// metadata.ts
/**
* Metadata arbitraria — pares clave/valor para datos adicionales
* como precios, códigos ERP, imágenes, etc.
*/
export type Metadata = Record<string, unknown>;

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

@ -2,11 +2,19 @@
// ─────────────────────────────────────────────────────────────────────────────
// OBJECT
// ─────────────────────────────────────────────────────────────────────────────
import type {ID} from "@/tsat";
import type {Att} from "@/vcen/lib/types/att.ts";
import type {Sec} from "@/vcen/lib/types/sec.ts";
import type {RuleSet} from "@/vcen/lib/types/rule.ts";
import type {ObjID} from "@/vcen/types/node.ts";
import type {Att} from "@/vcen/types/att.ts";
import type {Sec} from "@/vcen/types/sec.ts";
import type {RuleSet} from "@/vcen/types/rule.ts";
// ─────────────────────────────────────────────────────────────────────────────
// IDENTITY
// ─────────────────────────────────────────────────────────────────────────────
export const objIDPrefix = 'ob' as const;
export type ObjID = ID<typeof objIDPrefix>;
/**
* Object — entidad configurable dentro de un Catalog.

@ -0,0 +1,77 @@
import type {Metadata} from "@/vcen/lib/types/metadata.ts";
import type {LingString} from "@/ling";
import type { RuleSet} from "@/vcen/lib/types/rule.ts";
import type {ID} from "@/tsat";
// ─────────────────────────────────────────────────────────────────────────────
// OPTION
// ─────────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
// CATEGORY OPTION
// ─────────────────────────────────────────────────────────────────────────────
export const catOptIDPrefix = 'co' as const;
export type OptCatID = ID<typeof catOptIDPrefix>;
// ─────────────────────────────────────────────────────────────────────────────
// IDENTITY
// ─────────────────────────────────────────────────────────────────────────────
export const optIDPrefix = 'op' as const;
export type OptID = ID<typeof optIDPrefix>;
/**
* Tipo del valor de una Option.
* Una Option puede contener cualquier tipo escalar admitido por el sistema.
*/
export type OptValue =
| { kind: 'string'; value: string }
| { kind: 'number'; value: number }
| { kind: 'currency'; value: number }
| { kind: 'boolean'; value: boolean };
/**
* Categoría de Options — agrupa Options relacionadas para facilitar
* la selección en la UI (ej: 'Colores', 'Materiales', 'Motorizaciones').
*
* Las categorías viven en `Cat.optCategories`.
* Cada `Opt` referencia opcionalmente su categoría mediante `categoryId`.
*
* @example
* { id: 'oc:colores', name: 'Colores' }
*/
export interface OptCategory {
id: OptCatID;
name: string;
}
/**
* Option — entidad global del catálogo que representa un valor
* seleccionable en atributos de tipo `option`.
*
* Las Options viven en `Cat.opts` y son referenciadas desde
* los dominios de los atributos mediante su `id`.
*
* Una Option puede tener sus propias `rules`, que determinan
* si la opción está disponible en el contexto actual del Obj
* que contiene el Att que la referencia.
*
* @example
* {
* id: 'op:rojo',
* categoryId: 'oc:colores',
* value: { kind: 'string', value: 'rojo' },
* label: { es: 'Rojo', en: 'Red' },
* rules: { rules: [ ruleRojoOnlySport ] }
* }
*/
export interface Opt {
id: OptID;
categoryId?: OptCatID; // categoría a la que pertenece (opcional)
value: OptValue;
label?: LingString;
metadata?: Metadata;
rules?: RuleSet;
}

@ -1,7 +1,8 @@
import type { AttID, SecID, OptID } from "./node.ts";
import type {ISODate, ISOTime, ISODatetime, ID} from "@/tsat/types";
import type {AttValue} from "@/vcen/types/att.ts";
import type {AttID, AttValue} from "@/vcen/lib/types/att.ts";
import type {JsonLogicValue} from "@/jslg";
import type {SecID} from "@/vcen/lib/types/sec.ts";
import type {OptID} from "@/vcen/lib/types/opt.ts";
// ─────────────────────────────────────────────────────────────────────────────
// RULE ID
@ -11,20 +12,12 @@ export const ruleIDPrefix = 'rl' as const;
export type RuleID = ID<typeof ruleIDPrefix>;
// ─────────────────────────────────────────────────────────────────────────────
// JSONLOGIC
// ─────────────────────────────────────────────────────────────────────────────
/**
* Re-export de JsonLogicValue como alias semántico para condiciones de reglas.
* Usar `RuleCondition` en el dominio, `JsonLogicValue` solo en el motor.
*/
export type RuleCondition = JsonLogicValue;
// ─────────────────────────────────────────────────────────────────────────────
// RELATIVE PATHS
// ─────────────────────────────────────────────────────────────────────────────
@ -52,10 +45,6 @@ export type RelativePath =
| [AttID] // atributo en mismo contenedor (Att | Sec | Obj)
| [SecID, AttID]; // atributo en sección hija (Sec | Obj)
/** Serializa un RelativePath a string para usar en `{ "var": "..." }` */
export function serializeRelativePath(path: RelativePath): string {
return path.join('/');
}
// ─────────────────────────────────────────────────────────────────────────────
// ATTRIBUTE STATE
@ -130,17 +119,26 @@ export type RuleLevel = 'error' | 'warning';
/**
* Acción sobre el estado completo de un atributo.
*
* La condición de la regla determina qué estado se aplica:
* - `ifTrue` — estado cuando la condición SE CUMPLE
* - `ifFalse` — estado cuando la condición NO SE CUMPLE (por defecto `'available'`)
*
* En caso de conflicto con otras reglas sobre el mismo target,
* se aplica la jerarquía de precedencia de `AttState`.
*
* @example
* // Motor eléctrico → tanque oculto
* { type: 'attribute', target: ['at:tanque'], state: 'hidden' }
* // Motor eléctrico → tanque oculto; si no → visible
* { type: 'attribute', target: ['at:tanque'], ifTrue: 'hidden', ifFalse: 'available' }
*
* @example
* // Acabado lujo → tapicería obligatoria; si no → opcional
* { type: 'attribute', target: ['at:tapiceria'], ifTrue: 'required', ifFalse: 'available' }
*/
export interface RuleActionAttribute {
type: 'attribute';
target: RelativePath;
state: AttState;
type: 'attribute';
target: RelativePath;
ifTrue: AttState;
ifFalse: AttState; // default: 'available'
}
/**

@ -1,10 +1,20 @@
// ─────────────────────────────────────────────────────────────────────────────
// SECTION
// ─────────────────────────────────────────────────────────────────────────────
import type {Att} from "@/vcen/lib/types/att.ts";
import type {RuleSet} from "@/vcen/lib/types/rule.ts";
import type {ID} from "@/tsat";
// ─────────────────────────────────────────────────────────────────────────────
// IDENTITY
// ─────────────────────────────────────────────────────────────────────────────
export const secIDPrefix = 'sc' as const;
export type SecID = ID<typeof secIDPrefix>;
import type {SecID} from "@/vcen/types/node.ts";
import type {Att} from "@/vcen/types/att.ts";
import type {RuleSet} from "@/vcen/types/rule.ts";
/**
* Sección — agrupación lógica de atributos dentro de un Object.

@ -2,7 +2,7 @@
// TYPES
// ─────────────────────────────────────────────────────────────────────────────
import type {EvaluationResult} from "@/vcen/types/rule.ts";
import type {EvaluationResult} from "@/vcen/lib/types/rule.ts";
export type SessionListener = (result: EvaluationResult) => void;

@ -2,7 +2,7 @@
// RESULT
// ─────────────────────────────────────────────────────────────────────────────
import type {RuleLevel} from "@/vcen/types/rule.ts";
import type {RuleLevel} from "@/vcen/lib/types/rule.ts";
/**
* Resultado de una validación de dominio (capa 0).
@ -21,7 +21,7 @@ export type DomainValidationResult =
*/
export type DomainViolation =
| { code: 'VALUE_NOT_IN_LIST'; value: unknown; list: unknown[] }
| { code: 'VALUE_OUT_OF_RANGE'; value: number; min: number; max: number }
| { code: 'VALUE_OUT_OF_RANGE'; value: number | string; min: number | string; max: number | string }
| { code: 'VALUE_STEP_MISMATCH'; value: number; min: number; step: number }
| { code: 'VALUE_PATTERN_MISMATCH';value: string; pattern: string }
| { code: 'MULTIPLE_TOO_FEW'; count: number; min: number }

@ -8,12 +8,10 @@ import type {
AttCurrencyDomain,
AttBooleanDomain,
AttOptionDomain,
OptID, DomainValidationResult, DomainViolation,
OptID, DomainValidationResult, DomainViolation, AttOpt,
} from "./types";
// ─────────────────────────────────────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────────────────────────────────────
@ -171,8 +169,11 @@ function validateOption(
return err({ code: 'WRONG_TYPE', value: v, expected: 'OptID' });
}
if (domain.mode === 'list' && !domain.list.includes(v as OptID)) {
return err({ code: 'VALUE_NOT_IN_LIST', value: v, list: domain.list });
if (domain.mode === 'list') {
const optIds = domain.list.map(o => o.optId);
if (!optIds.includes(v as OptID)) {
return err({ code: 'VALUE_NOT_IN_LIST', value: v, list: optIds });
}
}
if (domain.mode === 'fixed' && v !== domain.fixed) {
@ -211,7 +212,7 @@ function validateISOString(
}
if (domain.mode === 'range' && (v < domain.min || v > domain.max)) {
return err({ code: 'VALUE_OUT_OF_RANGE', value: v as unknown as number, min: domain.min as unknown as number, max: domain.max as unknown as number });
return err({ code: 'VALUE_OUT_OF_RANGE', value: v, min: domain.min, max: domain.max });
}
if (domain.mode === 'list' && !(domain.list as string[]).includes(v)) {
@ -255,6 +256,7 @@ export function validateAttValue(attValue: AttValue): DomainValidationResult {
switch (domain.kind) {
case 'string':
return validateString(domain, value as unknown);
case 'number':
case 'currency':
return validateNumber(domain, value as unknown);
@ -273,6 +275,11 @@ export function validateAttValue(attValue: AttValue): DomainValidationResult {
case 'datetime':
return validateISOString(domain, value as unknown, 'ISODatetime');
default: {
const _exhaustiveCheck: never = domain;
throw new Error(`Unhandled domain kind: ${(_exhaustiveCheck as any).kind}`);
}
}
}
@ -282,4 +289,4 @@ export function validateAttValue(attValue: AttValue): DomainValidationResult {
*/
export function validateAtt(att: Att): DomainValidationResult {
return validateAttValue(att.value);
}
}

@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import {validateAttValue} from "@/vcen/validation.ts";
import type {OptID} from "@/vcen/types";
import {toOptID } from "@/vcen/node_util.ts";
import {validateAttValue} from "@/vcen/lib/validation.ts";
import type {AttOpt, OptID} from "@/vcen/lib/types";
import {toOptID } from "@/vcen/lib/node_util.ts";
// ─────────────────────────────────────────────────────────────────────────────
@ -268,18 +268,20 @@ describe('boolean / fixed', () => {
// ─────────────────────────────────────────────────────────────────────────────
describe('option / list', () => {
// domain.list es AttOpt[] — cada entrada tiene optId
const makeAttOpt = (id: string): AttOpt => ({ optId: id as OptID });
const domain = {
kind: 'option' as const,
mode: 'list' as const,
list: [toOptID('rojo'),toOptID('verde'),toOptID('azul')] as OptID[],
list: [makeAttOpt('op:rojo'), makeAttOpt('op:verde'), makeAttOpt('op:azul')],
};
it('acepta OptID en lista', () => {
expect(validateAttValue({ domain, value: toOptID('rojo') })).toEqual(valid);
it('acepta OptID cuyo optId está en lista', () => {
expect(validateAttValue({ domain, value: 'op:rojo' as unknown as OptID })).toEqual(valid);
});
it('rechaza OptID fuera de lista', () => {
expect(validateAttValue({ domain, value: toOptID('negro') })).toEqual(invalid('VALUE_NOT_IN_LIST'));
it('rechaza OptID cuyo optId no está en lista', () => {
expect(validateAttValue({ domain, value: 'op:negro' as unknown as OptID })).toEqual(invalid('VALUE_NOT_IN_LIST'));
});
it('rechaza número como OptID', () => {
@ -288,15 +290,16 @@ describe('option / list', () => {
});
describe('option / multiple', () => {
const makeAttOpt = (id: string): AttOpt => ({ optId: id as OptID });
const domain = {
kind: 'option' as const,
mode: 'list' as const,
list: [toOptID('a'), toOptID('b'), toOptID('c')] as OptID[],
kind: 'option' as const,
mode: 'list' as const,
list: [makeAttOpt('op:a'), makeAttOpt('op:b'), makeAttOpt('op:c')],
multiple: { min: 1, max: 2 },
};
it('acepta selección múltiple válida', () => {
expect(validateAttValue({ domain, value: [toOptID('a'),toOptID('b')] as OptID[] })).toEqual(valid);
expect(validateAttValue({ domain, value: ['op:a', 'op:b'] as unknown as OptID[] })).toEqual(valid);
});
it('rechaza selección vacía', () => {
@ -304,7 +307,7 @@ describe('option / multiple', () => {
});
it('rechaza más opciones del máximo', () => {
expect(validateAttValue({ domain, value: [toOptID('a'),toOptID ('b'), toOptID('c')] as unknown as OptID[] })).toEqual(invalid('MULTIPLE_TOO_MANY'));
expect(validateAttValue({ domain, value: ['op:a', 'op:b', 'op:c'] as unknown as OptID[] })).toEqual(invalid('MULTIPLE_TOO_MANY'));
});
});

@ -4,10 +4,10 @@ import {
evaluateSec,
evaluateObj,
evaluateCat,
} from '../engine';
import type { Att, Sec, Obj, Cat } from '../types';
import type { Rule } from '../types';
import {toRuleID} from "@/vcen/node_util.ts";
} from '../lib/engine';
import type { Att, Sec, Obj, Cat } from '../lib/types';
import type { Opt, AttOpt, OptID } from '../lib/types';
import type { Rule } from '../lib/types';
// ─────────────────────────────────────────────────────────────────────────────
// FIXTURES — builders minimalistas
@ -37,6 +37,33 @@ function makeNumberAtt(id: string, value: number, rules?: Rule[]): Att {
};
}
function makeOptAtt(id: string, optId: OptID, attOpts: AttOpt[], rules?: Rule[]): Att {
return {
id: id as Att['id'],
name: id,
value: {
domain: { kind: 'option', mode: 'list', list: attOpts },
value: optId,
},
rules: rules ? { rules } : undefined,
};
}
function makeOpt(id: string, value: string, rules?: Rule[]): Opt {
return {
id: id as Opt['id'],
value: { kind: 'string', value },
rules: rules ? { rules } : undefined,
};
}
function makeAttOpt(optId: string, rules?: Rule[]): AttOpt {
return {
optId: optId as OptID,
rules: rules ? { rules } : undefined,
};
}
function makeSec(id: string, atts: Att[], rules?: Rule[]): Sec {
return {
id: id as Sec['id'],
@ -56,10 +83,12 @@ function makeObj(id: string, atts: Att[], secs: Sec[] = [], rules?: Rule[]): Obj
};
}
function makeCat(id: string, objs: Obj[], atts: Att[] = []): Cat {
function makeCat(id: string, objs: Obj[], atts: Att[] = [], opts: Opt[] = []): Cat {
return {
id: id as Cat['id'],
name: id,
id: id as Cat['id'],
name: id,
optCategories: [],
opts,
atts,
objs,
};
@ -67,7 +96,7 @@ function makeCat(id: string, objs: Obj[], atts: Att[] = []): Cat {
function makeRule(id: string, condition: unknown, actions: Rule['actions'], priority = 10): Rule {
return {
id: toRuleID(id),
id: `rl_${id}` as Rule['id'],
priority,
condition: condition as Rule['condition'],
actions,
@ -80,67 +109,67 @@ function makeRule(id: string, condition: unknown, actions: Rule['actions'], prio
describe('evaluateAtt', () => {
it('devuelve estado available por defecto si no hay reglas', () => {
const att = makeAtt('at_color', 'rojo');
const state = { 'ct_cat/ob_obj/at_color': 'rojo' };
const result = evaluateAtt(att, 'ct_cat/ob_obj', state);
const att = makeAtt('at:color', 'rojo');
const state = { 'ct:cat/ob:obj/at:color': 'rojo' };
const result = evaluateAtt(att, 'ct:cat/ob:obj', state);
expect(result.state).toBe('available');
expect(result.forbiddenValues).toEqual([]);
expect(result.messages).toEqual([]);
});
it('aplica acción attribute → hidden cuando condición se cumple', () => {
const rule = makeRule('hide-color', { '==': [{ var: 'at_tipo' }, 'base'] }, [
{ type: 'attribute', target: [], state: 'hidden' },
const rule = makeRule('hide-color', { '==': [{ var: 'at:tipo' }, 'base'] }, [
{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' },
]);
const att = makeAtt('at_color', 'rojo', [rule]);
const state = { 'ct_cat/ob_obj/at_color': 'rojo', 'ct_cat/ob_obj/at_tipo': 'base' };
const result = evaluateAtt(att, 'ct_cat/ob_obj', state);
const att = makeAtt('at:color', 'rojo', [rule]);
const state = { 'ct:cat/ob:obj/at:color': 'rojo', 'ct:cat/ob:obj/at:tipo': 'base' };
const result = evaluateAtt(att, 'ct:cat/ob:obj', state);
expect(result.state).toBe('hidden');
});
it('no aplica acción si la condición no se cumple', () => {
const rule = makeRule('hide-color', { '==': [{ var: 'at_tipo' }, 'base'] }, [
{ type: 'attribute', target: [], state: 'hidden' },
const rule = makeRule('hide-color', { '==': [{ var: 'at:tipo' }, 'base'] }, [
{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' },
]);
const att = makeAtt('at_color', 'rojo', [rule]);
const state = { 'ct_cat/ob_obj/at_color': 'rojo', 'ct_cat/ob_obj/at_tipo': 'premium' };
const result = evaluateAtt(att, 'ct_cat/ob_obj', state);
const att = makeAtt('at:color', 'rojo', [rule]);
const state = { 'ct:cat/ob:obj/at:color': 'rojo', 'ct:cat/ob:obj/at:tipo': 'premium' };
const result = evaluateAtt(att, 'ct:cat/ob:obj', state);
expect(result.state).toBe('available');
});
it('aplica acción domain → forbidden sobre valor concreto', () => {
const rule = makeRule('piel-forbidden', { '!=': [{ var: 'at_acabado' }, 'lujo'] }, [
const rule = makeRule('piel-forbidden', { '!=': [{ var: 'at:acabado' }, 'lujo'] }, [
{ type: 'domain', target: [], value: 'piel', state: 'forbidden' },
]);
const att = makeAtt('at_tapiceria', 'piel', [rule]);
const att = makeAtt('at:tapiceria', 'piel', [rule]);
const state = {
'ct_cat/ob_obj/at_tapiceria': 'piel',
'ct_cat/ob_obj/at_acabado': 'standard',
'ct:cat/ob:obj/at:tapiceria': 'piel',
'ct:cat/ob:obj/at:acabado': 'standard',
};
const result = evaluateAtt(att, 'ct_cat/ob_obj', state);
const result = evaluateAtt(att, 'ct:cat/ob:obj', state);
expect(result.forbiddenValues).toContain('piel');
});
it('acumula mensajes de validación', () => {
const rule = makeRule('warning-color', { '==': [{ var: 'at_color' }, 'rojo'] }, [
const rule = makeRule('warning-color', { '==': [{ var: 'at:color' }, 'rojo'] }, [
{ type: 'validation', level: 'warning', message: 'El rojo no está recomendado' },
]);
const att = makeAtt('at_color', 'rojo', [rule]);
const state = { 'ct_cat/ob_obj/at_color': 'rojo' };
const result = evaluateAtt(att, 'ct_cat/ob_obj', state);
const att = makeAtt('at:color', 'rojo', [rule]);
const state = { 'ct:cat/ob:obj/at:color': 'rojo' };
const result = evaluateAtt(att, 'ct:cat/ob:obj', state);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].level).toBe('warning');
expect(result.messages[0].message).toBe('El rojo no está recomendado');
});
it('aplica múltiples acciones de la misma regla', () => {
const rule = makeRule('multi-action', { '==': [{ var: 'at_tipo' }, 'base'] }, [
{ type: 'attribute', target: [], state: 'required' },
const rule = makeRule('multi-action', { '==': [{ var: 'at:tipo' }, 'base'] }, [
{ type: 'attribute', target: [], ifTrue: 'required', ifFalse: 'available' },
{ type: 'validation', level: 'warning', message: 'Revisar configuración base' },
]);
const att = makeAtt('at_color', 'rojo', [rule]);
const state = { 'ct_cat/ob_obj/at_color': 'rojo', 'ct_cat/ob_obj/at_tipo': 'base' };
const result = evaluateAtt(att, 'ct_cat/ob_obj', state);
const att = makeAtt('at:color', 'rojo', [rule]);
const state = { 'ct:cat/ob:obj/at:color': 'rojo', 'ct:cat/ob:obj/at:tipo': 'base' };
const result = evaluateAtt(att, 'ct:cat/ob:obj', state);
expect(result.state).toBe('required');
expect(result.messages).toHaveLength(1);
});
@ -152,43 +181,43 @@ describe('evaluateAtt', () => {
describe('evaluateSec', () => {
it('devuelve estado available para todos los atts sin reglas', () => {
const sec = makeSec('sc_motor', [makeAtt('at_fuel', 'gasolina'), makeAtt('at_cv', '200')]);
const sec = makeSec('sc:motor', [makeAtt('at:fuel', 'gasolina'), makeAtt('at:cv', '200')]);
const state = {
'ct_cat/ob_obj/sc_motor/at_fuel': 'gasolina',
'ct_cat/ob_obj/sc_motor/at_cv': '200',
'ct:cat/ob:obj/sc:motor/at:fuel': 'gasolina',
'ct:cat/ob:obj/sc:motor/at:cv': '200',
};
const result = evaluateSec(sec, 'ct_cat/ob_obj', state);
expect(result.get('ct_cat/ob_obj/sc_motor/at_fuel')?.state).toBe('available');
expect(result.get('ct_cat/ob_obj/sc_motor/at_cv')?.state).toBe('available');
const result = evaluateSec(sec, 'ct:cat/ob:obj', state);
expect(result.get('ct:cat/ob:obj/sc:motor/at:fuel')?.state).toBe('available');
expect(result.get('ct:cat/ob:obj/sc:motor/at:cv')?.state).toBe('available');
});
it('regla de sección afecta a atributo de la sección', () => {
const rule = makeRule('hide-cv', { '==': [{ var: 'at_fuel' }, 'electrico'] }, [
{ type: 'attribute', target: ['at_cv' as Att['id']], state: 'hidden' },
const rule = makeRule('hide-cv', { '==': [{ var: 'at:fuel' }, 'electrico'] }, [
{ type: 'attribute', target: ['at:cv' as Att['id']], ifTrue: 'hidden', ifFalse: 'available' },
]);
const sec = makeSec('sc_motor', [makeAtt('at_fuel', 'electrico'), makeAtt('at_cv', '0')], [rule]);
const sec = makeSec('sc:motor', [makeAtt('at:fuel', 'electrico'), makeAtt('at:cv', '0')], [rule]);
const state = {
'ct_cat/ob_obj/sc_motor/at_fuel': 'electrico',
'ct_cat/ob_obj/sc_motor/at_cv': '0',
'ct:cat/ob:obj/sc:motor/at:fuel': 'electrico',
'ct:cat/ob:obj/sc:motor/at:cv': '0',
};
const result = evaluateSec(sec, 'ct_cat/ob_obj', state);
expect(result.get('ct_cat/ob_obj/sc_motor/at_cv')?.state).toBe('hidden');
const result = evaluateSec(sec, 'ct:cat/ob:obj', state);
expect(result.get('ct:cat/ob:obj/sc:motor/at:cv')?.state).toBe('hidden');
});
it('regla de atributo dentro de sección se evalúa correctamente', () => {
const attRule = makeRule('require-cv', { '!=': [{ var: 'at_fuel' }, 'electrico'] }, [
{ type: 'attribute', target: [], state: 'required' },
const attRule = makeRule('require-cv', { '!=': [{ var: 'at:fuel' }, 'electrico'] }, [
{ type: 'attribute', target: [], ifTrue: 'required', ifFalse: 'available' },
]);
const sec = makeSec('sc_motor', [
makeAtt('at_fuel', 'gasolina'),
makeAtt('at_cv', '200', [attRule]),
const sec = makeSec('sc:motor', [
makeAtt('at:fuel', 'gasolina'),
makeAtt('at:cv', '200', [attRule]),
]);
const state = {
'ct_cat/ob_obj/sc_motor/at_fuel': 'gasolina',
'ct_cat/ob_obj/sc_motor/at_cv': '200',
'ct:cat/ob:obj/sc:motor/at:fuel': 'gasolina',
'ct:cat/ob:obj/sc:motor/at:cv': '200',
};
const result = evaluateSec(sec, 'ct_cat/ob_obj', state);
expect(result.get('ct_cat/ob_obj/sc_motor/at_cv')?.state).toBe('required');
const result = evaluateSec(sec, 'ct:cat/ob:obj', state);
expect(result.get('ct:cat/ob:obj/sc:motor/at:cv')?.state).toBe('required');
});
});
@ -198,42 +227,42 @@ describe('evaluateSec', () => {
describe('evaluateObj', () => {
it('regla de objeto afecta a atributo directo', () => {
const rule = makeRule('hide-color', { '==': [{ var: 'at_tipo' }, 'base'] }, [
{ type: 'attribute', target: ['at_color' as Att['id']], state: 'hidden' },
const rule = makeRule('hide-color', { '==': [{ var: 'at:tipo' }, 'base'] }, [
{ type: 'attribute', target: ['at:color' as Att['id']], ifTrue: 'hidden', ifFalse: 'available' },
]);
const obj = makeObj('ob_bmw', [makeAtt('at_tipo', 'base'), makeAtt('at_color', 'rojo')], [], [rule]);
const obj = makeObj('ob:bmw', [makeAtt('at:tipo', 'base'), makeAtt('at:color', 'rojo')], [], [rule]);
const state = {
'ct_cat/ob_bmw/at_tipo': 'base',
'ct_cat/ob_bmw/at_color': 'rojo',
'ct:cat/ob:bmw/at:tipo': 'base',
'ct:cat/ob:bmw/at:color': 'rojo',
};
const result = evaluateObj(obj, 'ct_cat', state);
expect(result.get('ct_cat/ob_bmw/at_color')?.state).toBe('hidden');
const result = evaluateObj(obj, 'ct:cat', state);
expect(result.get('ct:cat/ob:bmw/at:color')?.state).toBe('hidden');
});
it('regla de objeto afecta a atributo en sección hija', () => {
const rule = makeRule('hide-cv', { '==': [{ var: 'at_motor' }, 'electrico'] }, [
{ type: 'attribute', target: ['sc_motor' as Sec['id'], 'at_cv' as Att['id']], state: 'hidden' },
const rule = makeRule('hide-cv', { '==': [{ var: 'at:motor' }, 'electrico'] }, [
{ type: 'attribute', target: ['sc:motor' as Sec['id'], 'at:cv' as Att['id']], ifTrue: 'hidden', ifFalse: 'available' },
]);
const sec = makeSec('sc_motor', [makeAtt('at_cv', '0')]);
const obj = makeObj('ob_bmw', [makeAtt('at_motor', 'electrico')], [sec], [rule]);
const sec = makeSec('sc:motor', [makeAtt('at:cv', '0')]);
const obj = makeObj('ob:bmw', [makeAtt('at:motor', 'electrico')], [sec], [rule]);
const state = {
'ct_cat/ob_bmw/at_motor': 'electrico',
'ct_cat/ob_bmw/sc_motor/at_cv': '0',
'ct:cat/ob:bmw/at:motor': 'electrico',
'ct:cat/ob:bmw/sc:motor/at:cv': '0',
};
const result = evaluateObj(obj, 'ct_cat', state);
expect(result.get('ct_cat/ob_bmw/sc_motor/at_cv')?.state).toBe('hidden');
const result = evaluateObj(obj, 'ct:cat', state);
expect(result.get('ct:cat/ob:bmw/sc:motor/at:cv')?.state).toBe('hidden');
});
it('inicializa todos los atts con estado available aunque no tengan reglas', () => {
const sec = makeSec('sc_extras', [makeAtt('at_techo', 'no')]);
const obj = makeObj('ob_bmw', [makeAtt('at_color', 'rojo')], [sec]);
const sec = makeSec('sc:extras', [makeAtt('at:techo', 'no')]);
const obj = makeObj('ob:bmw', [makeAtt('at:color', 'rojo')], [sec]);
const state = {
'ct_cat/ob_bmw/at_color': 'rojo',
'ct_cat/ob_bmw/sc_extras/at_techo': 'no',
'ct:cat/ob:bmw/at:color': 'rojo',
'ct:cat/ob:bmw/sc:extras/at:techo': 'no',
};
const result = evaluateObj(obj, 'ct_cat', state);
expect(result.get('ct_cat/ob_bmw/at_color')?.state).toBe('available');
expect(result.get('ct_cat/ob_bmw/sc_extras/at_techo')?.state).toBe('available');
const result = evaluateObj(obj, 'ct:cat', state);
expect(result.get('ct:cat/ob:bmw/at:color')?.state).toBe('available');
expect(result.get('ct:cat/ob:bmw/sc:extras/at:techo')?.state).toBe('available');
});
});
@ -243,29 +272,29 @@ describe('evaluateObj', () => {
describe('resolución de conflictos', () => {
it('forbidden gana sobre hidden', () => {
const r1 = makeRule('r1', true, [{ type: 'attribute', target: [], state: 'hidden' }], 10);
const r2 = makeRule('r2', true, [{ type: 'attribute', target: [], state: 'forbidden'}], 20);
const att = makeAtt('at_color', 'rojo', [r1, r2]);
const state = { 'ct_cat/ob_obj/at_color': 'rojo' };
const result = evaluateAtt(att, 'ct_cat/ob_obj', state);
const r1 = makeRule('r1', true, [{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }], 10);
const r2 = makeRule('r2', true, [{ type: 'attribute', target: [], ifTrue: 'forbidden', ifFalse: 'available'}], 20);
const att = makeAtt('at:color', 'rojo', [r1, r2]);
const state = { 'ct:cat/ob:obj/at:color': 'rojo' };
const result = evaluateAtt(att, 'ct:cat/ob:obj', state);
expect(result.state).toBe('forbidden');
});
it('hidden gana sobre required', () => {
const r1 = makeRule('r1', true, [{ type: 'attribute', target: [], state: 'required' }], 10);
const r2 = makeRule('r2', true, [{ type: 'attribute', target: [], state: 'hidden' }], 20);
const att = makeAtt('at_color', 'rojo', [r1, r2]);
const state = { 'ct_cat/ob_obj/at_color': 'rojo' };
const result = evaluateAtt(att, 'ct_cat/ob_obj', state);
const r1 = makeRule('r1', true, [{ type: 'attribute', target: [], ifTrue: 'required', ifFalse: 'available' }], 10);
const r2 = makeRule('r2', true, [{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }], 20);
const att = makeAtt('at:color', 'rojo', [r1, r2]);
const state = { 'ct:cat/ob:obj/at:color': 'rojo' };
const result = evaluateAtt(att, 'ct:cat/ob:obj', state);
expect(result.state).toBe('hidden');
});
it('required gana sobre available', () => {
const r1 = makeRule('r1', true, [{ type: 'attribute', target: [], state: 'available' }], 10);
const r2 = makeRule('r2', true, [{ type: 'attribute', target: [], state: 'required' }], 20);
const att = makeAtt('at_color', 'rojo', [r1, r2]);
const state = { 'ct_cat/ob_obj/at_color': 'rojo' };
const result = evaluateAtt(att, 'ct_cat/ob_obj', state);
const r1 = makeRule('r1', true, [{ type: 'attribute', target: [], ifTrue: 'available', ifFalse: 'available' }], 10);
const r2 = makeRule('r2', true, [{ type: 'attribute', target: [], ifTrue: 'required', ifFalse: 'available' }], 20);
const att = makeAtt('at:color', 'rojo', [r1, r2]);
const state = { 'ct:cat/ob:obj/at:color': 'rojo' };
const result = evaluateAtt(att, 'ct:cat/ob:obj', state);
expect(result.state).toBe('required');
});
@ -274,9 +303,9 @@ describe('resolución de conflictos', () => {
const valueB = { domain: { kind: 'string' as const, mode: 'input' as const }, value: 'B' };
const r1 = makeRule('r1', true, [{ type: 'value', target: [], value: valueA }], 5);
const r2 = makeRule('r2', true, [{ type: 'value', target: [], value: valueB }], 10);
const att = makeAtt('at_color', 'rojo', [r1, r2]);
const state = { 'ct_cat/ob_obj/at_color': 'rojo' };
const result = evaluateAtt(att, 'ct_cat/ob_obj', state);
const att = makeAtt('at:color', 'rojo', [r1, r2]);
const state = { 'ct:cat/ob:obj/at:color': 'rojo' };
const result = evaluateAtt(att, 'ct:cat/ob:obj', state);
expect(result.derivedValue?.value).toBe('A');
});
@ -284,18 +313,18 @@ describe('resolución de conflictos', () => {
const r1 = makeRule('r1', true, [{ type: 'domain', target: [], value: 'piel', state: 'forbidden' }], 10);
const r2 = makeRule('r2', true, [{ type: 'domain', target: [], value: 'madera', state: 'forbidden' }], 20);
const r3 = makeRule('r3', true, [{ type: 'domain', target: [], value: 'piel', state: 'forbidden' }], 30);
const att = makeAtt('at_tapiceria', 'tela', [r1, r2, r3]);
const state = { 'ct_cat/ob_obj/at_tapiceria': 'tela' };
const result = evaluateAtt(att, 'ct_cat/ob_obj', state);
const att = makeAtt('at:tapiceria', 'tela', [r1, r2, r3]);
const state = { 'ct:cat/ob:obj/at:tapiceria': 'tela' };
const result = evaluateAtt(att, 'ct:cat/ob:obj', state);
expect(result.forbiddenValues).toEqual(['piel', 'madera']); // sin duplicados
});
it('domain allowed elimina un forbidden previo', () => {
const r1 = makeRule('r1', true, [{ type: 'domain', target: [], value: 'piel', state: 'forbidden' }], 10);
const r2 = makeRule('r2', true, [{ type: 'domain', target: [], value: 'piel', state: 'allowed' }], 20);
const att = makeAtt('at_tapiceria', 'tela', [r1, r2]);
const state = { 'ct_cat/ob_obj/at_tapiceria': 'tela' };
const result = evaluateAtt(att, 'ct_cat/ob_obj', state);
const att = makeAtt('at:tapiceria', 'tela', [r1, r2]);
const state = { 'ct:cat/ob:obj/at:tapiceria': 'tela' };
const result = evaluateAtt(att, 'ct:cat/ob:obj', state);
expect(result.forbiddenValues).not.toContain('piel');
});
});
@ -307,36 +336,36 @@ describe('resolución de conflictos', () => {
describe('derivación de valores', () => {
it('fuerza el valor de un atributo cuando la condición se cumple', () => {
const derivedValue = { domain: { kind: 'number' as const, mode: 'fixed' as const, fixed: 0 }, value: 0 };
const rule = makeRule('emisiones-zero', { '==': [{ var: 'at_motor' }, 'electrico'] }, [
{ type: 'value', target: ['at_emisiones' as Att['id']], value: derivedValue },
const rule = makeRule('emisiones-zero', { '==': [{ var: 'at:motor' }, 'electrico'] }, [
{ type: 'value', target: ['at:emisiones' as Att['id']], value: derivedValue },
]);
const obj = makeObj('ob_bmw', [
makeAtt('at_motor', 'electrico'),
makeNumberAtt('at_emisiones', 120),
const obj = makeObj('ob:bmw', [
makeAtt('at:motor', 'electrico'),
makeNumberAtt('at:emisiones', 120),
], [], [rule]);
const state = {
'ct_cat/ob_bmw/at_motor': 'electrico',
'ct_cat/ob_bmw/at_emisiones': 120,
'ct:cat/ob:bmw/at:motor': 'electrico',
'ct:cat/ob:bmw/at:emisiones': 120,
};
const result = evaluateObj(obj, 'ct_cat', state);
expect(result.get('ct_cat/ob_bmw/at_emisiones')?.derivedValue?.value).toBe(0);
const result = evaluateObj(obj, 'ct:cat', state);
expect(result.get('ct:cat/ob:bmw/at:emisiones')?.derivedValue?.value).toBe(0);
});
it('no fuerza el valor si la condición no se cumple', () => {
const derivedValue = { domain: { kind: 'number' as const, mode: 'fixed' as const, fixed: 0 }, value: 0 };
const rule = makeRule('emisiones-zero', { '==': [{ var: 'at_motor' }, 'electrico'] }, [
{ type: 'value', target: ['at_emisiones' as Att['id']], value: derivedValue },
const rule = makeRule('emisiones-zero', { '==': [{ var: 'at:motor' }, 'electrico'] }, [
{ type: 'value', target: ['at:emisiones' as Att['id']], value: derivedValue },
]);
const obj = makeObj('ob_bmw', [
makeAtt('at_motor', 'diesel'),
makeNumberAtt('at_emisiones', 120),
const obj = makeObj('ob:bmw', [
makeAtt('at:motor', 'diesel'),
makeNumberAtt('at:emisiones', 120),
], [], [rule]);
const state = {
'ct_cat/ob_bmw/at_motor': 'diesel',
'ct_cat/ob_bmw/at_emisiones': 120,
'ct:cat/ob:bmw/at:motor': 'diesel',
'ct:cat/ob:bmw/at:emisiones': 120,
};
const result = evaluateObj(obj, 'ct_cat', state);
expect(result.get('ct_cat/ob_bmw/at_emisiones')?.derivedValue).toBeUndefined();
const result = evaluateObj(obj, 'ct:cat', state);
expect(result.get('ct:cat/ob:bmw/at:emisiones')?.derivedValue).toBeUndefined();
});
});
@ -346,8 +375,8 @@ describe('derivación de valores', () => {
describe('evaluateCat', () => {
it('configuración válida sin reglas activas', () => {
const obj = makeObj('ob_bmw', [makeAtt('at_color', 'rojo')]);
const cat = makeCat('ct_cars', [obj]);
const obj = makeObj('ob:bmw', [makeAtt('at:color', 'rojo')]);
const cat = makeCat('ct:cars', [obj]);
const result = evaluateCat(cat);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
@ -355,52 +384,52 @@ describe('evaluateCat', () => {
});
it('detecta error cuando valor está en forbiddenValues', () => {
const rule = makeRule('piel-forbidden', { '!=': [{ var: 'at_acabado' }, 'lujo'] }, [
const rule = makeRule('piel-forbidden', { '!=': [{ var: 'at:acabado' }, 'lujo'] }, [
{ type: 'domain', target: [], value: 'piel', state: 'forbidden' },
]);
const obj = makeObj('ob_bmw', [
makeAtt('at_acabado', 'standard'),
makeAtt('at_tapiceria', 'piel', [rule]),
const obj = makeObj('ob:bmw', [
makeAtt('at:acabado', 'standard'),
makeAtt('at:tapiceria', 'piel', [rule]),
]);
const cat = makeCat('ct_cars', [obj]);
const cat = makeCat('ct:cars', [obj]);
const result = evaluateCat(cat);
expect(result.valid).toBe(false);
expect(result.errors).toContain('ct_cars/ob_bmw/at_tapiceria');
expect(result.errors).toContain('ct:cars/ob:bmw/at:tapiceria');
});
it('configuración válida cuando valor no está en forbiddenValues', () => {
const rule = makeRule('piel-forbidden', { '!=': [{ var: 'at_acabado' }, 'lujo'] }, [
const rule = makeRule('piel-forbidden', { '!=': [{ var: 'at:acabado' }, 'lujo'] }, [
{ type: 'domain', target: [], value: 'piel', state: 'forbidden' },
]);
const obj = makeObj('ob_bmw', [
makeAtt('at_acabado', 'lujo'),
makeAtt('at_tapiceria', 'piel', [rule]),
const obj = makeObj('ob:bmw', [
makeAtt('at:acabado', 'lujo'),
makeAtt('at:tapiceria', 'piel', [rule]),
]);
const cat = makeCat('ct_cars', [obj]);
const cat = makeCat('ct:cars', [obj]);
const result = evaluateCat(cat);
expect(result.valid).toBe(true);
});
it('acumula warnings sin invalidar la configuración', () => {
const rule = makeRule('warning-color', { '==': [{ var: 'at_color' }, 'rojo'] }, [
const rule = makeRule('warning-color', { '==': [{ var: 'at:color' }, 'rojo'] }, [
{ type: 'validation', level: 'warning', message: 'Color llamativo' },
]);
const obj = makeObj('ob_bmw', [makeAtt('at_color', 'rojo', [rule])]);
const cat = makeCat('ct_cars', [obj]);
const obj = makeObj('ob:bmw', [makeAtt('at:color', 'rojo', [rule])]);
const cat = makeCat('ct:cars', [obj]);
const result = evaluateCat(cat);
expect(result.valid).toBe(true);
expect(result.warnings).toContain('ct_cars/ob_bmw/at_color');
expect(result.warnings).toContain('ct:cars/ob:bmw/at:color');
});
it('error de validación invalida la configuración', () => {
const rule = makeRule('error-color', { '==': [{ var: 'at_color' }, 'rojo'] }, [
const rule = makeRule('error-color', { '==': [{ var: 'at:color' }, 'rojo'] }, [
{ type: 'validation', level: 'error', message: 'Color no permitido' },
]);
const obj = makeObj('ob_bmw', [makeAtt('at_color', 'rojo', [rule])]);
const cat = makeCat('ct_cars', [obj]);
const obj = makeObj('ob:bmw', [makeAtt('at:color', 'rojo', [rule])]);
const cat = makeCat('ct:cars', [obj]);
const result = evaluateCat(cat);
expect(result.valid).toBe(false);
expect(result.errors).toContain('ct_cars/ob_bmw/at_color');
expect(result.errors).toContain('ct:cars/ob:bmw/at:color');
});
});
@ -420,36 +449,36 @@ describe('forward chaining', () => {
};
const ruleDerive = makeRule('tipo-premium-lujo',
{ '==': [{ var: 'at_tipo' }, 'premium'] },
[{ type: 'value', target: ['at_acabado' as Att['id']], value: derivedLujo }],
{ '==': [{ var: 'at:tipo' }, 'premium'] },
[{ type: 'value', target: ['at:acabado' as Att['id']], value: derivedLujo }],
5,
);
const rulePiel = makeRule('piel-requires-lujo',
{ '!=': [{ var: 'at_acabado' }, 'lujo'] },
{ '!=': [{ var: 'at:acabado' }, 'lujo'] },
[{ type: 'domain', target: [], value: 'piel', state: 'forbidden' }],
10,
);
const obj = makeObj('ob_bmw', [
makeAtt('at_tipo', 'premium'),
makeAtt('at_acabado', 'standard'), // será derivado a 'lujo'
makeAtt('at_tapiceria', 'piel', [rulePiel]),
const obj = makeObj('ob:bmw', [
makeAtt('at:tipo', 'premium'),
makeAtt('at:acabado', 'standard'), // será derivado a 'lujo'
makeAtt('at:tapiceria', 'piel', [rulePiel]),
], [], [ruleDerive]);
const cat = makeCat('ct_cars', [obj]);
const cat = makeCat('ct:cars', [obj]);
const result = evaluateCat(cat);
// Tras propagación: acabado='lujo' → piel no debería estar forbidden
const tapiceriaState = result.effectiveState.get('ct_cars/ob_bmw/at_tapiceria');
const tapiceriaState = result.effectiveState.get('ct:cars/ob:bmw/at:tapiceria');
expect(tapiceriaState?.forbiddenValues).not.toContain('piel');
expect(result.valid).toBe(true);
expect(result.iterations).toBeGreaterThan(1); // necesitó más de 1 ciclo
});
it('estabiliza en 1 iteración cuando no hay encadenamiento', () => {
const obj = makeObj('ob_bmw', [makeAtt('at_color', 'rojo')]);
const cat = makeCat('ct_cars', [obj]);
const obj = makeObj('ob:bmw', [makeAtt('at:color', 'rojo')]);
const cat = makeCat('ct:cars', [obj]);
const result = evaluateCat(cat);
expect(result.iterations).toBe(1);
});
@ -467,20 +496,20 @@ describe('detección de ciclos', () => {
const valueX = { domain: { kind: 'string' as const, mode: 'input' as const }, value: 'x' };
const valueY = { domain: { kind: 'string' as const, mode: 'input' as const }, value: 'y' };
const ruleAtoB = makeRule('a-to-b', { '==': [{ var: 'at_a' }, 'trigger'] }, [
{ type: 'value', target: ['at_b' as Att['id']], value: valueX },
const ruleAtoB = makeRule('a-to-b', { '==': [{ var: 'at:a' }, 'trigger'] }, [
{ type: 'value', target: ['at:b' as Att['id']], value: valueX },
], 5);
const ruleBtoA = makeRule('b-to-a', { '==': [{ var: 'at_b' }, 'x'] }, [
{ type: 'value', target: ['at_a' as Att['id']], value: valueY },
const ruleBtoA = makeRule('b-to-a', { '==': [{ var: 'at:b' }, 'x'] }, [
{ type: 'value', target: ['at:a' as Att['id']], value: valueY },
], 5);
const obj = makeObj('ob_test', [
makeAtt('at_a', 'trigger'),
makeAtt('at_b', 'initial'),
const obj = makeObj('ob:test', [
makeAtt('at:a', 'trigger'),
makeAtt('at:b', 'initial'),
], [], [ruleAtoB, ruleBtoA]);
const cat = makeCat('ct_test', [obj]);
const cat = makeCat('ct:test', [obj]);
expect(() => evaluateCat(cat)).not.toThrow();
@ -488,4 +517,146 @@ describe('detección de ciclos', () => {
expect(result.iterations).toBeGreaterThanOrEqual(1);
expect(result.effectiveState.size).toBeGreaterThan(0);
});
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Options — evaluación de Opt y AttOpt
// ─────────────────────────────────────────────────────────────────────────────
describe('options', () => {
it('sin rules todas las options son válidas', () => {
const opts = [makeOpt('op:rojo', 'rojo'), makeOpt('op:azul', 'azul')];
const attOpts = opts.map(o => makeAttOpt(o.id));
const obj = makeObj('ob:bmw', [makeOptAtt('at:color', 'op:rojo' as OptID, attOpts)]);
const cat = makeCat('ct:cars', [obj], [], opts);
const result = evaluateCat(cat);
const es = result.effectiveState.get('ct:cars/ob:bmw/at:color');
expect(es?.state).toBe('available');
expect(es?.forbiddenValues).toHaveLength(0);
});
it('Opt cuya rule no se cumple queda en forbiddenValues', () => {
const ruleRojo = makeRule('rojo-only-lujo',
{ '==': [{ var: 'at:acabado' }, 'lujo'] },
[{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }],
);
const opts = [makeOpt('op:rojo', 'rojo', [ruleRojo]), makeOpt('op:azul', 'azul')];
const attOpts = opts.map(o => makeAttOpt(o.id));
const obj = makeObj('ob:bmw', [
makeAtt('at:acabado', 'standard'),
makeOptAtt('at:color', 'op:rojo' as OptID, attOpts),
]);
const cat = makeCat('ct:cars', [obj], [], opts);
const result = evaluateCat(cat);
const es = result.effectiveState.get('ct:cars/ob:bmw/at:color');
expect(es?.forbiddenValues).toContain('op:rojo');
expect(es?.forbiddenValues).not.toContain('op:azul');
});
it('valor actual inválido → derivedValue con siguiente opción válida', () => {
const ruleRojo = makeRule('rojo-forbidden',
{ '==': [{ var: 'at:acabado' }, 'lujo'] },
[{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }],
);
const opts = [makeOpt('op:rojo', 'rojo', [ruleRojo]), makeOpt('op:azul', 'azul')];
const attOpts = opts.map(o => makeAttOpt(o.id));
const obj = makeObj('ob:bmw', [
makeAtt('at:acabado', 'standard'), // rojo queda inválido
makeOptAtt('at:color', 'op:rojo' as OptID, attOpts), // valor actual = rojo
]);
const cat = makeCat('ct:cars', [obj], [], opts);
const result = evaluateCat(cat);
const es = result.effectiveState.get('ct:cars/ob:bmw/at:color');
expect(es?.derivedValue?.value).toBe('op:azul');
});
it('todas las options inválidas → att forbidden', () => {
const ruleTodas = makeRule('all-forbidden',
{ '==': [{ var: 'at:acabado' }, 'lujo'] },
[{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }],
);
const opts = [makeOpt('op:rojo', 'rojo', [ruleTodas]), makeOpt('op:azul', 'azul', [ruleTodas])];
const attOpts = opts.map(o => makeAttOpt(o.id));
const obj = makeObj('ob:bmw', [
makeAtt('at:acabado', 'standard'),
makeOptAtt('at:color', 'op:rojo' as OptID, attOpts),
]);
const cat = makeCat('ct:cars', [obj], [], opts);
const result = evaluateCat(cat);
const es = result.effectiveState.get('ct:cars/ob:bmw/at:color');
expect(es?.state).toBe('forbidden');
});
it('AttOpt rule local se evalúa en AND con rule global', () => {
// Opt global sin rule → siempre visible
// AttOpt rule local: solo si at:acabado=lujo → si no se cumple, forbidden
const ruleLocal = makeRule('local-lujo',
{ '==': [{ var: 'at:acabado' }, 'lujo'] },
[{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }],
);
const opts = [makeOpt('op:rojo', 'rojo')];
const attOpts = [makeAttOpt('op:rojo', [ruleLocal])];
const obj = makeObj('ob:bmw', [
makeAtt('at:acabado', 'standard'),
makeOptAtt('at:color', 'op:rojo' as OptID, attOpts),
]);
const cat = makeCat('ct:cars', [obj], [], opts);
const result = evaluateCat(cat);
const es = result.effectiveState.get('ct:cars/ob:bmw/at:color');
expect(es?.forbiddenValues).toContain('op:rojo');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Sticky state — valores derivados no persisten entre evaluaciones
// ─────────────────────────────────────────────────────────────────────────────
describe('sticky state', () => {
it('derivedValue desaparece cuando su regla deja de cumplirse', () => {
const derivedLujo = { domain: { kind: 'string' as const, mode: 'input' as const }, value: 'lujo' };
const ruleDerive = makeRule('tipo-premium-lujo',
{ '==': [{ var: 'at:tipo' }, 'premium'] },
[{ type: 'value', target: ['at:acabado' as Att['id']], value: derivedLujo }],
5,
);
// tipo=base → regla no se cumple → acabado no debe tener derivedValue
const obj = makeObj('ob:bmw', [
makeAtt('at:tipo', 'base'),
makeAtt('at:acabado', 'standard'),
], [], [ruleDerive]);
const cat = makeCat('ct:cars', [obj]);
const result = evaluateCat(cat);
expect(result.effectiveState.get('ct:cars/ob:bmw/at:acabado')?.derivedValue).toBeUndefined();
});
it('evaluateCat siempre parte del estado base, nunca del resultado anterior', () => {
const derivedLujo = { domain: { kind: 'string' as const, mode: 'input' as const }, value: 'lujo' };
const ruleDerive = makeRule('tipo-premium-lujo',
{ '==': [{ var: 'at:tipo' }, 'premium'] },
[{ type: 'value', target: ['at:acabado' as Att['id']], value: derivedLujo }],
5,
);
const rulePiel = makeRule('piel-requires-lujo',
{ '!=': [{ var: 'at:acabado' }, 'lujo'] },
[{ type: 'domain', target: [], value: 'piel', state: 'forbidden' }],
10,
);
// Primera evaluación: tipo=premium → acabado derivado a lujo → piel OK
const catPremium = makeCat('ct:cars', [makeObj('ob:bmw', [
makeAtt('at:tipo', 'premium'),
makeAtt('at:acabado', 'standard'),
makeAtt('at:tapiceria', 'piel', [rulePiel]),
], [], [ruleDerive])]);
const r1 = evaluateCat(catPremium);
expect(r1.effectiveState.get('ct:cars/ob:bmw/at:tapiceria')?.forbiddenValues).not.toContain('piel');
// Segunda evaluación: tipo=base → acabado NO derivado → piel debe ser forbidden
const catBase = makeCat('ct:cars', [makeObj('ob:bmw', [
makeAtt('at:tipo', 'base'),
makeAtt('at:acabado', 'standard'),
makeAtt('at:tapiceria', 'piel', [rulePiel]),
], [], [ruleDerive])]);
const r2 = evaluateCat(catBase);
expect(r2.effectiveState.get('ct:cars/ob:bmw/at:tapiceria')?.forbiddenValues).toContain('piel');
});
});

@ -7,10 +7,10 @@ import {
getDependents,
getTransitiveAffected,
DependencyCycleError
} from '../graph';
import type { Att, Sec, Obj, Cat } from '../types';
import type { Rule } from '../types';
import {toRuleID} from "@/vcen/node_util.ts";
} from '../lib/graph.ts';
import type {Att, Sec, Obj, Cat, Opt} from '../lib/types';
import type { Rule } from '../lib/types';
import {toRuleID} from "@/vcen/lib/node_util.ts";
// ─────────────────────────────────────────────────────────────────────────────
// FIXTURES
@ -33,12 +33,12 @@ function makeObj(id: string, atts: Att[], secs: Sec[] = [], rules?: Rule[]): Obj
return { id: id as Obj['id'], name: id, atts, secs, rules: rules ? { rules } : undefined };
}
function makeCat(id: string, objs: Obj[], atts: Att[] = []): Cat {
return { id: id as Cat['id'], name: id, atts, objs };
function makeCat(id: string, objs: Obj[], atts: Att[] = [], opts: Opt[] = []): Cat {
return { id: id as Cat['id'], name: id, optCategories: [], opts, atts, objs };
}
function makeRule(id: string, condition: unknown, actions: Rule['actions'], priority = 10): Rule {
return { id: toRuleID(id), priority, condition: condition as Rule['condition'], actions };
return { id: `rl_${id}` as Rule['id'], priority, condition: condition as Rule['condition'], actions };
}
// helpers para comparar AttPath como string en expects
@ -50,7 +50,7 @@ function ap(...parts: string[]) { return parts; } // AttPath como array
describe('buildDependencyGraph — estructura básica', () => {
it('cat sin reglas produce grafo sin aristas', () => {
const cat = makeCat('ct_cat', [makeObj('ob_obj', [makeAtt('at_color', 'rojo')])]);
const cat = makeCat('ct:cat', [makeObj('ob:obj', [makeAtt('at:color', 'rojo')])]);
const graph = buildDependencyGraph(cat);
expect(graph.attToRules.size).toBe(0);
expect(graph.ruleToAtts.size).toBe(0);
@ -58,52 +58,52 @@ describe('buildDependencyGraph — estructura básica', () => {
});
it('registra att→rule cuando la condición usa var', () => {
const rule = makeRule('r1', { '==': [{ var: 'at_acabado' }, 'lujo'] }, [
{ type: 'attribute', target: [], state: 'hidden' },
const rule = makeRule('r1', { '==': [{ var: 'at:acabado' }, 'lujo'] }, [
{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' },
]);
const obj = makeObj('ob_bmw', [makeAtt('at_acabado', 'standard'), makeAtt('at_color', 'rojo', [rule])]);
const cat = makeCat('ct_cars', [obj]);
const obj = makeObj('ob:bmw', [makeAtt('at:acabado', 'standard'), makeAtt('at:color', 'rojo', [rule])]);
const cat = makeCat('ct:cars', [obj]);
const graph = buildDependencyGraph(cat);
const rules = graph.attToRules.get('ct_cars/ob_bmw/at_acabado');
const rules = graph.attToRules.get('ct:cars/ob:bmw/at:acabado');
expect(rules?.has('rl_r1')).toBe(true);
});
it('registra rule→att para el target de la acción', () => {
const rule = makeRule('r1', { '==': [{ var: 'at_acabado' }, 'lujo'] }, [
{ type: 'attribute', target: [], state: 'hidden' },
const rule = makeRule('r1', { '==': [{ var: 'at:acabado' }, 'lujo'] }, [
{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' },
]);
const obj = makeObj('ob_bmw', [makeAtt('at_acabado', 'standard'), makeAtt('at_color', 'rojo', [rule])]);
const cat = makeCat('ct_cars', [obj]);
const obj = makeObj('ob:bmw', [makeAtt('at:acabado', 'standard'), makeAtt('at:color', 'rojo', [rule])]);
const cat = makeCat('ct:cars', [obj]);
const graph = buildDependencyGraph(cat);
const atts = graph.ruleToAtts.get('rl_r1');
expect(atts?.has('ct_cars/ob_bmw/at_color')).toBe(true);
expect(atts?.has('ct:cars/ob:bmw/at:color')).toBe(true);
});
it('registra att→att: at_color depende de at_acabado', () => {
const rule = makeRule('r1', { '==': [{ var: 'at_acabado' }, 'lujo'] }, [
{ type: 'attribute', target: [], state: 'hidden' },
it('registra att→att: at:color depende de at:acabado', () => {
const rule = makeRule('r1', { '==': [{ var: 'at:acabado' }, 'lujo'] }, [
{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' },
]);
const obj = makeObj('ob_bmw', [makeAtt('at_acabado', 'standard'), makeAtt('at_color', 'rojo', [rule])]);
const cat = makeCat('ct_cars', [obj]);
const obj = makeObj('ob:bmw', [makeAtt('at:acabado', 'standard'), makeAtt('at:color', 'rojo', [rule])]);
const cat = makeCat('ct:cars', [obj]);
const graph = buildDependencyGraph(cat);
expect(graph.attDeps.get('ct_cars/ob_bmw/at_color')?.has('ct_cars/ob_bmw/at_acabado')).toBe(true);
expect(graph.attDependents.get('ct_cars/ob_bmw/at_acabado')?.has('ct_cars/ob_bmw/at_color')).toBe(true);
expect(graph.attDeps.get('ct:cars/ob:bmw/at:color')?.has('ct:cars/ob:bmw/at:acabado')).toBe(true);
expect(graph.attDependents.get('ct:cars/ob:bmw/at:acabado')?.has('ct:cars/ob:bmw/at:color')).toBe(true);
});
it('guarda ruleMeta con ownerPath y rule', () => {
const rule = makeRule('r1', { '==': [{ var: 'at_acabado' }, 'lujo'] }, [
{ type: 'attribute', target: [], state: 'hidden' },
const rule = makeRule('r1', { '==': [{ var: 'at:acabado' }, 'lujo'] }, [
{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' },
]);
const obj = makeObj('ob_bmw', [makeAtt('at_acabado', 'standard'), makeAtt('at_color', 'rojo', [rule])]);
const cat = makeCat('ct_cars', [obj]);
const obj = makeObj('ob:bmw', [makeAtt('at:acabado', 'standard'), makeAtt('at:color', 'rojo', [rule])]);
const cat = makeCat('ct:cars', [obj]);
const graph = buildDependencyGraph(cat);
const meta = graph.ruleMeta.get('rl_r1');
expect(meta).toBeDefined();
expect(meta!.ownerPath).toEqual(ap('ct_cars', 'ob_bmw', 'at_color'));
expect(meta!.ownerPath).toEqual(ap('ct:cars', 'ob:bmw', 'at:color'));
expect(meta!.rule.id).toBe('rl_r1');
});
});
@ -114,57 +114,57 @@ describe('buildDependencyGraph — estructura básica', () => {
describe('buildDependencyGraph — niveles', () => {
it('regla en Sec crea dependencia att→att correcta', () => {
const rule = makeRule('r1', { '==': [{ var: 'at_fuel' }, 'electrico'] }, [
{ type: 'attribute', target: ['at_cv' as Att['id']], state: 'hidden' },
const rule = makeRule('r1', { '==': [{ var: 'at:fuel' }, 'electrico'] }, [
{ type: 'attribute', target: ['at:cv' as Att['id']], ifTrue: 'hidden', ifFalse: 'available' },
]);
const sec = makeSec('sc:motor', [makeAtt('at_fuel', 'gasolina'), makeAtt('at_cv', '200')], [rule]);
const obj = makeObj('ob_bmw', [], [sec]);
const cat = makeCat('ct_cars', [obj]);
const sec = makeSec('sc:motor', [makeAtt('at:fuel', 'gasolina'), makeAtt('at:cv', '200')], [rule]);
const obj = makeObj('ob:bmw', [], [sec]);
const cat = makeCat('ct:cars', [obj]);
const graph = buildDependencyGraph(cat);
expect(graph.attDeps.get('ct_cars/ob_bmw/sc:motor/at_cv')
?.has('ct_cars/ob_bmw/sc:motor/at_fuel')).toBe(true);
expect(graph.attDeps.get('ct:cars/ob:bmw/sc:motor/at:cv')
?.has('ct:cars/ob:bmw/sc:motor/at:fuel')).toBe(true);
});
it('regla en Obj puede referenciar att en sección hija', () => {
const rule = makeRule('r1', { '==': [{ var: 'at_motor' }, 'electrico'] }, [
{ type: 'attribute', target: ['sc:motor' as Sec['id'], 'at_cv' as Att['id']], state: 'hidden' },
const rule = makeRule('r1', { '==': [{ var: 'at:motor' }, 'electrico'] }, [
{ type: 'attribute', target: ['sc:motor' as Sec['id'], 'at:cv' as Att['id']], ifTrue: 'hidden', ifFalse: 'available' },
]);
const sec = makeSec('sc:motor', [makeAtt('at_cv', '200')]);
const obj = makeObj('ob_bmw', [makeAtt('at_motor', 'electrico')], [sec], [rule]);
const cat = makeCat('ct_cars', [obj]);
const sec = makeSec('sc:motor', [makeAtt('at:cv', '200')]);
const obj = makeObj('ob:bmw', [makeAtt('at:motor', 'electrico')], [sec], [rule]);
const cat = makeCat('ct:cars', [obj]);
const graph = buildDependencyGraph(cat);
expect(graph.attDeps.get('ct_cars/ob_bmw/sc:motor/at_cv')
?.has('ct_cars/ob_bmw/at_motor')).toBe(true);
expect(graph.attDeps.get('ct:cars/ob:bmw/sc:motor/at:cv')
?.has('ct:cars/ob:bmw/at:motor')).toBe(true);
});
it('regla de validación apunta al ownerPath', () => {
const rule = makeRule('r1', { '==': [{ var: 'at_color' }, 'rojo'] }, [
const rule = makeRule('r1', { '==': [{ var: 'at:color' }, 'rojo'] }, [
{ type: 'validation', level: 'warning', message: 'Color llamativo' },
]);
const obj = makeObj('ob_bmw', [makeAtt('at_color', 'rojo', [rule])]);
const cat = makeCat('ct_cars', [obj]);
const obj = makeObj('ob:bmw', [makeAtt('at:color', 'rojo', [rule])]);
const cat = makeCat('ct:cars', [obj]);
const graph = buildDependencyGraph(cat);
expect(graph.ruleToAtts.get('rl_r1')?.has('ct_cars/ob_bmw/at_color')).toBe(true);
expect(graph.ruleToAtts.get('rl_r1')?.has('ct:cars/ob:bmw/at:color')).toBe(true);
});
it('condición con múltiples var crea múltiples att→rule', () => {
const rule = makeRule('r1',
{ and: [{ '==': [{ var: 'at_motor' }, 'electrico'] }, { '==': [{ var: 'at_acabado' }, 'lujo'] }] },
[{ type: 'attribute', target: [], state: 'hidden' }],
{ and: [{ '==': [{ var: 'at:motor' }, 'electrico'] }, { '==': [{ var: 'at:acabado' }, 'lujo'] }] },
[{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }],
);
const obj = makeObj('ob_bmw', [
makeAtt('at_motor', 'electrico'),
makeAtt('at_acabado', 'lujo'),
makeAtt('at_color', 'rojo', [rule]),
const obj = makeObj('ob:bmw', [
makeAtt('at:motor', 'electrico'),
makeAtt('at:acabado', 'lujo'),
makeAtt('at:color', 'rojo', [rule]),
]);
const cat = makeCat('ct_cars', [obj]);
const cat = makeCat('ct:cars', [obj]);
const graph = buildDependencyGraph(cat);
expect(graph.attToRules.get('ct_cars/ob_bmw/at_motor')?.has('rl_r1')).toBe(true);
expect(graph.attToRules.get('ct_cars/ob_bmw/at_acabado')?.has('rl_r1')).toBe(true);
expect(graph.attToRules.get('ct:cars/ob:bmw/at:motor')?.has('rl_r1')).toBe(true);
expect(graph.attToRules.get('ct:cars/ob:bmw/at:acabado')?.has('rl_r1')).toBe(true);
});
});
@ -174,79 +174,79 @@ describe('buildDependencyGraph — niveles', () => {
describe('query API', () => {
function makeSimpleGraph() {
const r1 = makeRule('r1', { '!=': [{ var: 'at_acabado' }, 'lujo'] }, [
const r1 = makeRule('r1', { '!=': [{ var: 'at:acabado' }, 'lujo'] }, [
{ type: 'domain', target: [], value: 'piel', state: 'forbidden' },
]);
const r2 = makeRule('r2', { '==': [{ var: 'at_acabado' }, 'base'] }, [
{ type: 'attribute', target: [], state: 'hidden' },
const r2 = makeRule('r2', { '==': [{ var: 'at:acabado' }, 'base'] }, [
{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' },
]);
const obj = makeObj('ob_bmw', [
makeAtt('at_acabado', 'standard'),
makeAtt('at_tapiceria', 'tela', [r1]),
makeAtt('at_color', 'rojo', [r2]),
const obj = makeObj('ob:bmw', [
makeAtt('at:acabado', 'standard'),
makeAtt('at:tapiceria', 'tela', [r1]),
makeAtt('at:color', 'rojo', [r2]),
]);
return makeCat('ct_cars', [obj]);
return makeCat('ct:cars', [obj]);
}
it('getAffectedAtts devuelve los atts que dependen del att dado', () => {
const graph = buildDependencyGraph(makeSimpleGraph());
const affected = getAffectedAtts(graph, ap('ct_cars', 'ob_bmw', 'at_acabado') as any);
const affected = getAffectedAtts(graph, ap('ct:cars', 'ob:bmw', 'at:acabado') as any);
const keys = affected.map(p => p.join('/'));
expect(keys).toContain('ct_cars/ob_bmw/at_tapiceria');
expect(keys).toContain('ct_cars/ob_bmw/at_color');
expect(keys).toContain('ct:cars/ob:bmw/at:tapiceria');
expect(keys).toContain('ct:cars/ob:bmw/at:color');
});
it('getAffectedAtts devuelve [] para att sin dependientes', () => {
const graph = buildDependencyGraph(makeSimpleGraph());
expect(getAffectedAtts(graph, ap('ct_cars', 'ob_bmw', 'at_tapiceria') as any)).toEqual([]);
expect(getAffectedAtts(graph, ap('ct:cars', 'ob:bmw', 'at:tapiceria') as any)).toEqual([]);
});
it('getAffectedRules devuelve las reglas que leen el att', () => {
const graph = buildDependencyGraph(makeSimpleGraph());
const rules = getAffectedRules(graph, ap('ct_cars', 'ob_bmw', 'at_acabado') as any);
const rules = getAffectedRules(graph, ap('ct:cars', 'ob:bmw', 'at:acabado') as any);
expect(rules).toContain('rl_r1');
expect(rules).toContain('rl_r2');
});
it('getDependencies devuelve los inputs de un att', () => {
const graph = buildDependencyGraph(makeSimpleGraph());
const deps = getDependencies(graph, ap('ct_cars', 'ob_bmw', 'at_tapiceria') as any);
expect(deps.map(p => p.join('/'))).toContain('ct_cars/ob_bmw/at_acabado');
const deps = getDependencies(graph, ap('ct:cars', 'ob:bmw', 'at:tapiceria') as any);
expect(deps.map(p => p.join('/'))).toContain('ct:cars/ob:bmw/at:acabado');
});
it('getDependents devuelve los outputs de un att', () => {
const graph = buildDependencyGraph(makeSimpleGraph());
const deps = getDependents(graph, ap('ct_cars', 'ob_bmw', 'at_acabado') as any);
const deps = getDependents(graph, ap('ct:cars', 'ob:bmw', 'at:acabado') as any);
const keys = deps.map(p => p.join('/'));
expect(keys).toContain('ct_cars/ob_bmw/at_tapiceria');
expect(keys).toContain('ct_cars/ob_bmw/at_color');
expect(keys).toContain('ct:cars/ob:bmw/at:tapiceria');
expect(keys).toContain('ct:cars/ob:bmw/at:color');
});
it('getTransitiveAffected recorre toda la cadena A→B→C', () => {
const r1 = makeRule('r1', { '==': [{ var: 'at_motor' }, 'electrico'] }, [
{ type: 'value', target: ['at_emisiones' as Att['id']], value: {
const r1 = makeRule('r1', { '==': [{ var: 'at:motor' }, 'electrico'] }, [
{ type: 'value', target: ['at:emisiones' as Att['id']], value: {
domain: { kind: 'number', mode: 'fixed', fixed: 0 }, value: 0,
}},
]);
const r2 = makeRule('r2', { '==': [{ var: 'at_emisiones' }, '0'] }, [
{ type: 'attribute', target: [], state: 'hidden' },
const r2 = makeRule('r2', { '==': [{ var: 'at:emisiones' }, '0'] }, [
{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' },
]);
const obj = makeObj('ob_bmw', [
makeAtt('at_motor', 'electrico'),
makeAtt('at_emisiones', '120', [r1]),
makeAtt('at_etiqueta', 'C', [r2]),
const obj = makeObj('ob:bmw', [
makeAtt('at:motor', 'electrico'),
makeAtt('at:emisiones', '120', [r1]),
makeAtt('at:etiqueta', 'C', [r2]),
]);
const cat = makeCat('ct_cars', [obj]);
const cat = makeCat('ct:cars', [obj]);
const graph = buildDependencyGraph(cat);
const trans = getTransitiveAffected(graph, ap('ct_cars', 'ob_bmw', 'at_motor') as any);
const trans = getTransitiveAffected(graph, ap('ct:cars', 'ob:bmw', 'at:motor') as any);
const keys = trans.map(p => p.join('/'));
expect(keys).toContain('ct_cars/ob_bmw/at_emisiones');
expect(keys).toContain('ct_cars/ob_bmw/at_etiqueta');
expect(keys).toContain('ct:cars/ob:bmw/at:emisiones');
expect(keys).toContain('ct:cars/ob:bmw/at:etiqueta');
});
it('getTransitiveAffected devuelve [] para att sin dependientes', () => {
const graph = buildDependencyGraph(makeSimpleGraph());
expect(getTransitiveAffected(graph, ap('ct_cars', 'ob_bmw', 'at_tapiceria') as any)).toEqual([]);
expect(getTransitiveAffected(graph, ap('ct:cars', 'ob:bmw', 'at:tapiceria') as any)).toEqual([]);
});
});
@ -256,59 +256,59 @@ describe('query API', () => {
describe('detección de ciclos', () => {
it('no lanza error si no hay ciclos', () => {
const rule = makeRule('r1', { '==': [{ var: 'at_a' }, 'x'] }, [
{ type: 'attribute', target: [], state: 'hidden' },
const rule = makeRule('r1', { '==': [{ var: 'at:a' }, 'x'] }, [
{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' },
]);
const obj = makeObj('ob_obj', [makeAtt('at_a', 'x'), makeAtt('at_b', 'y', [rule])]);
expect(() => buildDependencyGraph(makeCat('ct_cat', [obj]))).not.toThrow();
const obj = makeObj('ob:obj', [makeAtt('at:a', 'x'), makeAtt('at:b', 'y', [rule])]);
expect(() => buildDependencyGraph(makeCat('ct:cat', [obj]))).not.toThrow();
});
it('lanza DependencyCycleError con ciclo directo A→B→A', () => {
const rAtoB = makeRule('r-a-b', { '==': [{ var: 'at_a' }, 'x'] }, [
{ type: 'attribute', target: ['at_b' as Att['id']], state: 'hidden' },
const rAtoB = makeRule('r-a-b', { '==': [{ var: 'at:a' }, 'x'] }, [
{ type: 'attribute', target: ['at:b' as Att['id']], ifTrue: 'hidden', ifFalse: 'available' },
]);
const rBtoA = makeRule('r-b-a', { '==': [{ var: 'at_b' }, 'y'] }, [
{ type: 'attribute', target: ['at_a' as Att['id']], state: 'hidden' },
const rBtoA = makeRule('r-b-a', { '==': [{ var: 'at:b' }, 'y'] }, [
{ type: 'attribute', target: ['at:a' as Att['id']], ifTrue: 'hidden', ifFalse: 'available' },
]);
const obj = makeObj('ob_obj', [makeAtt('at_a', 'x', [rBtoA]), makeAtt('at_b', 'y', [rAtoB])]);
expect(() => buildDependencyGraph(makeCat('ct_cat', [obj]))).toThrow(DependencyCycleError);
const obj = makeObj('ob:obj', [makeAtt('at:a', 'x', [rBtoA]), makeAtt('at:b', 'y', [rAtoB])]);
expect(() => buildDependencyGraph(makeCat('ct:cat', [obj]))).toThrow(DependencyCycleError);
});
it('el error incluye los paths del ciclo', () => {
const rAtoB = makeRule('r-a-b', { '==': [{ var: 'at_a' }, 'x'] }, [
{ type: 'attribute', target: ['at_b' as Att['id']], state: 'hidden' },
const rAtoB = makeRule('r-a-b', { '==': [{ var: 'at:a' }, 'x'] }, [
{ type: 'attribute', target: ['at:b' as Att['id']], ifTrue: 'hidden', ifFalse: 'available' },
]);
const rBtoA = makeRule('r-b-a', { '==': [{ var: 'at_b' }, 'y'] }, [
{ type: 'attribute', target: ['at_a' as Att['id']], state: 'hidden' },
const rBtoA = makeRule('r-b-a', { '==': [{ var: 'at:b' }, 'y'] }, [
{ type: 'attribute', target: ['at:a' as Att['id']], ifTrue: 'hidden', ifFalse: 'available' },
]);
const obj = makeObj('ob_obj', [makeAtt('at_a', 'x', [rBtoA]), makeAtt('at_b', 'y', [rAtoB])]);
const obj = makeObj('ob:obj', [makeAtt('at:a', 'x', [rBtoA]), makeAtt('at:b', 'y', [rAtoB])]);
try {
buildDependencyGraph(makeCat('ct_cat', [obj]));
buildDependencyGraph(makeCat('ct:cat', [obj]));
expect.fail('debería haber lanzado');
} catch (e) {
expect(e).toBeInstanceOf(DependencyCycleError);
const err = e as DependencyCycleError;
const allPaths = err.cycles.flatMap(c => c.path.map((p: any[]) => p.join('/')));
expect(allPaths.some(p => p.includes('at_a'))).toBe(true);
expect(allPaths.some(p => p.includes('at_b'))).toBe(true);
const allPaths = err.cycles.flatMap(c => c.path.map(p => p.join('/')));
expect(allPaths.some(p => p.includes('at:a'))).toBe(true);
expect(allPaths.some(p => p.includes('at:b'))).toBe(true);
}
});
it('lanza DependencyCycleError con ciclo transitivo A→B→C→A', () => {
const rAtoB = makeRule('r-a-b', { '==': [{ var: 'at_a' }, 'x'] }, [
{ type: 'attribute', target: ['at_b' as Att['id']], state: 'hidden' },
const rAtoB = makeRule('r-a-b', { '==': [{ var: 'at:a' }, 'x'] }, [
{ type: 'attribute', target: ['at:b' as Att['id']], ifTrue: 'hidden', ifFalse: 'available' },
]);
const rBtoC = makeRule('r-b-c', { '==': [{ var: 'at_b' }, 'y'] }, [
{ type: 'attribute', target: ['at_c' as Att['id']], state: 'hidden' },
const rBtoC = makeRule('r-b-c', { '==': [{ var: 'at:b' }, 'y'] }, [
{ type: 'attribute', target: ['at:c' as Att['id']], ifTrue: 'hidden', ifFalse: 'available' },
]);
const rCtoA = makeRule('r-c-a', { '==': [{ var: 'at_c' }, 'z'] }, [
{ type: 'attribute', target: ['at_a' as Att['id']], state: 'hidden' },
const rCtoA = makeRule('r-c-a', { '==': [{ var: 'at:c' }, 'z'] }, [
{ type: 'attribute', target: ['at:a' as Att['id']], ifTrue: 'hidden', ifFalse: 'available' },
]);
const obj = makeObj('ob_obj', [
makeAtt('at_a', 'x', [rCtoA]),
makeAtt('at_b', 'y', [rAtoB]),
makeAtt('at_c', 'z', [rBtoC]),
const obj = makeObj('ob:obj', [
makeAtt('at:a', 'x', [rCtoA]),
makeAtt('at:b', 'y', [rAtoB]),
makeAtt('at:c', 'z', [rBtoC]),
]);
expect(() => buildDependencyGraph(makeCat('ct_cat', [obj]))).toThrow(DependencyCycleError);
expect(() => buildDependencyGraph(makeCat('ct:cat', [obj]))).toThrow(DependencyCycleError);
});
});
});

@ -1,9 +1,9 @@
import { describe, it, expect } from 'vitest';
import { evaluateIncremental } from '../incremental';
import { evaluateCat } from '../engine';
import { buildDependencyGraph } from '../graph';
import type { Att, Sec, Obj, Cat, Rule } from '../types';
import {toRuleID} from "@/vcen/node_util.ts";
import { evaluateIncremental } from '../lib/incremental.ts';
import { evaluateCat } from '../lib/engine.ts';
import { buildDependencyGraph } from '../lib/graph.ts';
import type {Att, Sec, Obj, Cat, Rule, Opt} from '../lib/types';
import {toRuleID} from "@/vcen/lib/node_util.ts";
@ -19,16 +19,21 @@ function makeAtt(id: string, value: unknown = 'x', rules?: Rule[]): Att {
rules: rules ? { rules } : undefined,
};
}
function makeSec(id: string, atts: Att[], rules?: Rule[]): Sec {
return { id: id as Sec['id'], name: id, atts, rules: rules ? { rules } : undefined };
}
function makeObj(id: string, atts: Att[], secs: Sec[] = [], rules?: Rule[]): Obj {
return { id: id as Obj['id'], name: id, atts, secs, rules: rules ? { rules } : undefined };
}
function makeCat(id: string, objs: Obj[], atts: Att[] = []): Cat {
return { id: id as Cat['id'], name: id, atts, objs };
function makeCat(id: string, objs: Obj[], atts: Att[] = [], opts: Opt[] = []): Cat {
return { id: id as Cat['id'], name: id, optCategories: [], opts, atts, objs };
}
function makeRule(id: string, condition: unknown, actions: Rule['actions'], priority = 10): Rule {
return { id: toRuleID(id), priority, condition: condition as Rule['condition'], actions };
return { id: `rl_${id}` as Rule['id'], priority, condition: condition as Rule['condition'], actions };
}
function ap(...parts: string[]) { return parts as any; }
@ -112,7 +117,7 @@ describe('evaluateIncremental — re-evaluación de atts afectados', () => {
it('no modifica atts no afectados por el cambio', () => {
const rule = makeRule('r1', { '==': [{ var: 'at_acabado' }, 'lujo'] }, [
{ type: 'attribute', target: [], state: 'required' },
{ type: 'attribute', target: [], ifTrue: 'required', ifFalse: 'available' },
]);
const obj = makeObj('ob_bmw', [
makeAtt('at_acabado', 'standard'),
@ -139,13 +144,13 @@ describe('evaluateIncremental — re-evaluación de atts afectados', () => {
const rDerive = makeRule('r-derive',
{ '==': [{ var: 'at_motor' }, 'electrico'] },
[{ type: 'value', target: ['at_emisiones' as Att['id']], value: {
domain: { kind: 'string', mode: 'input' }, value: '0',
}}],
domain: { kind: 'string', mode: 'input' }, value: '0',
}}],
5,
);
const rHide = makeRule('r-hide',
{ '==': [{ var: 'at_emisiones' }, '0'] },
[{ type: 'attribute', target: [], state: 'hidden' }],
[{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }],
10,
);
const obj = makeObj('ob_bmw', [

@ -1,7 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { createSession } from '../session';
import type { Att, Sec, Obj, Cat , Rule } from '../types';
import {toRuleID} from "@/vcen/node_util.ts";
import { createSession } from '../lib/session.ts';
import type {Att, Sec, Obj, Cat, Rule, Opt} from '../lib/types';
import {toRuleID} from "@/vcen/lib/node_util.ts";
// ─────────────────────────────────────────────────────────────────────────────
// FIXTURES
@ -24,12 +24,12 @@ function makeObj(id: string, atts: Att[], secs: Sec[] = [], rules?: Rule[]): Obj
return { id: id as Obj['id'], name: id, atts, secs, rules: rules ? { rules } : undefined };
}
function makeCat(id: string, objs: Obj[], atts: Att[] = []): Cat {
return { id: id as Cat['id'], name: id, atts, objs };
function makeCat(id: string, objs: Obj[], atts: Att[] = [], opts: Opt[] = []): Cat {
return { id: id as Cat['id'], name: id, optCategories: [], opts, atts, objs };
}
function makeRule(id: string, condition: unknown, actions: Rule['actions'], priority = 10): Rule {
return { id: toRuleID(id), priority, condition: condition as Rule['condition'], actions };
return { id: `rl_${id}` as Rule['id'], priority, condition: condition as Rule['condition'], actions };
}
function ap(...parts: string[]) { return parts as any; }
@ -50,10 +50,10 @@ describe('createSession', () => {
it('lanza DependencyCycleError si el cat tiene ciclos', () => {
const rAtoB = makeRule('r-a-b', { '==': [{ var: 'at_a' }, 'x'] }, [
{ type: 'attribute', target: ['at_b' as Att['id']], state: 'hidden' },
{ type: 'attribute', target: ['at_b' as Att['id']], ifTrue: 'hidden', ifFalse: 'available' },
]);
const rBtoA = makeRule('r-b-a', { '==': [{ var: 'at_b' }, 'y'] }, [
{ type: 'attribute', target: ['at_a' as Att['id']], state: 'hidden' },
{ type: 'attribute', target: ['at_a' as Att['id']], ifTrue: 'hidden', ifFalse: 'available' },
]);
const obj = makeObj('ob_obj', [
makeAtt('at_a', 'x', [rBtoA]),
@ -78,8 +78,8 @@ describe('getValue', () => {
it('devuelve el valor derivado si existe', () => {
const rule = makeRule('r1', { '==': [{ var: 'at_motor' }, 'electrico'] }, [
{ type: 'value', target: ['at_emisiones' as Att['id']], value: {
domain: { kind: 'string', mode: 'input' }, value: '0',
}},
domain: { kind: 'string', mode: 'input' }, value: '0',
}},
]);
const obj = makeObj('ob_bmw', [
makeAtt('at_motor', 'electrico'),
@ -117,7 +117,7 @@ describe('getEffectiveState', () => {
it('refleja el estado tras setValue', () => {
const rule = makeRule('r1', { '==': [{ var: 'at_acabado' }, 'lujo'] }, [
{ type: 'attribute', target: [], state: 'required' },
{ type: 'attribute', target: [], ifTrue: 'required', ifFalse: 'available' },
]);
const obj = makeObj('ob_bmw', [
makeAtt('at_acabado', 'standard'),
@ -195,7 +195,7 @@ describe('isValid / getErrors / getWarnings', () => {
describe('reset', () => {
it('vuelve al estado inicial tras varios setValue', () => {
const rule = makeRule('r1', { '==': [{ var: 'at_acabado' }, 'lujo'] }, [
{ type: 'attribute', target: [], state: 'required' },
{ type: 'attribute', target: [], ifTrue: 'required', ifFalse: 'available' },
]);
const obj = makeObj('ob_bmw', [
makeAtt('at_acabado', 'standard'),

@ -1,13 +0,0 @@
export * from './att';
export * from './cat';
export * from './graph';
export * from './incremental';
export * from './node';
export * from './obj';
export * from './rule';
export * from './sec';
export * from './validation.ts'
Loading…
Cancel
Save

Powered by TurnKey Linux.