diff --git a/demo.html b/demo.html new file mode 100644 index 0000000..bed3143 --- /dev/null +++ b/demo.html @@ -0,0 +1,519 @@ + + + + + + ConditionBuilder — Demo + + + + + + +
+ + +
+
+

ConditionBuilder

+

Editor de condiciones JsonLogic — Demo

+
+
✓ válida
+
+ +
+ + +
+
+ Condición + +
+
+
+
+ + +
+ + +
+ Variables del scope +
+
+ + +
+ JsonLogicValue +
—
+
+ + +
+ Errores +
+
+ +
+
+
+ + + + + diff --git a/index.html b/index.html index d40841f..574f785 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,7 @@
- + \ No newline at end of file diff --git a/src/vcen/editor/App.svelte b/src/vcen/editor/App.svelte new file mode 100644 index 0000000..9dddf9c --- /dev/null +++ b/src/vcen/editor/App.svelte @@ -0,0 +1,15 @@ + + +
+ + + +
+ + diff --git a/src/vcen/editor/app.css b/src/vcen/editor/app.css new file mode 100644 index 0000000..5dfc65b --- /dev/null +++ b/src/vcen/editor/app.css @@ -0,0 +1,15 @@ +@import "tailwindcss"; + + +/* Smooth scroll para navegación */ +html { + scroll-behavior: smooth; +} + + +@layer components { + + +} + + diff --git a/src/vcen/editor/comps/CompareNodeEditor.svelte b/src/vcen/editor/comps/CompareNodeEditor.svelte new file mode 100644 index 0000000..c8a158c --- /dev/null +++ b/src/vcen/editor/comps/CompareNodeEditor.svelte @@ -0,0 +1,182 @@ + + +
+
+ + + + + + + + + {#if !scopeEntry} + + + {:else if node.op === 'date-between'} + + y + + + {:else if scopeEntry.domain.kind === 'option'} + {@const opts = (scopeEntry.domain as any).options ?? []} + {#if opts.length > 0} + + {:else} + + {/if} + + {:else if scopeEntry.domain.kind === 'boolean'} + + + {:else if scopeEntry.domain.kind === 'number' || scopeEntry.domain.kind === 'currency'} + + + {:else if scopeEntry.domain.kind === 'date'} + + + {:else if scopeEntry.domain.kind === 'time'} + + + {:else if scopeEntry.domain.kind === 'datetime'} + + + {:else} + + {/if} + + + + +
+ + {#if inputError} +

{inputError}

+ {:else if error} +

{error.message}

+ {/if} +
\ No newline at end of file diff --git a/src/vcen/editor/comps/ConditionBuilder.svelte b/src/vcen/editor/comps/ConditionBuilder.svelte new file mode 100644 index 0000000..1e3083a --- /dev/null +++ b/src/vcen/editor/comps/ConditionBuilder.svelte @@ -0,0 +1,31 @@ + + +
+ + {#if store.tautology.kind === 'always-true'} +
+ ⚠️ Esta condición siempre es verdadera +
+ {:else if store.tautology.kind === 'always-false'} +
+ ⚠️ Esta condición siempre es falsa +
+ {/if} + + + +
diff --git a/src/vcen/editor/comps/ConditionNode.svelte b/src/vcen/editor/comps/ConditionNode.svelte new file mode 100644 index 0000000..dd2a42b --- /dev/null +++ b/src/vcen/editor/comps/ConditionNode.svelte @@ -0,0 +1,20 @@ + + +{#if node.kind === 'logic'} + +{:else} + +{/if} diff --git a/src/vcen/editor/comps/LogicNodeEditor.svelte b/src/vcen/editor/comps/LogicNodeEditor.svelte new file mode 100644 index 0000000..89c6d33 --- /dev/null +++ b/src/vcen/editor/comps/LogicNodeEditor.svelte @@ -0,0 +1,64 @@ + + +
+ +
+ + + se cumplen + +
+ + + + + {#if depth > 0} + + {/if} +
+
+ + {#if node.children.length === 0} +

Sin condiciones — añade una arriba

+ {:else} +
+ {#each node.children as child (child.id)} + + {/each} +
+ {/if} + +
\ No newline at end of file diff --git a/src/vcen/editor/comps/demos/AttEditor.svelte b/src/vcen/editor/comps/demos/AttEditor.svelte new file mode 100644 index 0000000..aab4a6a --- /dev/null +++ b/src/vcen/editor/comps/demos/AttEditor.svelte @@ -0,0 +1,222 @@ + + +{#if !att} +
+ Selecciona un atributo para editarlo +
+{:else} +
+ + +
+ + +
+ + +
+ + +
+ + + {#if att.value.domain.kind === 'option'} +
+ +
+ + +
+
+ {#each (att.value.domain as any).options ?? [] as opt} + {opt} + {/each} +
+
+ {/if} + + +
+ + + {#if att.value.domain.kind === 'option'} + + + {:else if att.value.domain.kind === 'boolean'} + + + {:else if att.value.domain.kind === 'number' || att.value.domain.kind === 'currency'} + + + {:else if att.value.domain.kind === 'date'} + + + {:else if att.value.domain.kind === 'time'} + + + {:else if att.value.domain.kind === 'datetime'} + + + {:else} + + {/if} + +

+ Valor inicial cuando se carga el catálogo +

+
+ + +
+
+ + {#if att.rules?.rules?.length} + + {/if} +
+ + {#if cbStore} +
+ +
+
+

+ Si se cumple → atributo visible +

+ +
+ {/if} +
+ +
+{/if} \ No newline at end of file diff --git a/src/vcen/editor/comps/demos/CatalogDemo.svelte b/src/vcen/editor/comps/demos/CatalogDemo.svelte new file mode 100644 index 0000000..c3abeef --- /dev/null +++ b/src/vcen/editor/comps/demos/CatalogDemo.svelte @@ -0,0 +1,179 @@ + + +
+ + +
+

+ Catálogo Demo +

+ | + {store.cat.id} + + +
+ {#each PANELS as panel} + + {/each} +
+ + + {#if store.session} + + {store.session.isValid() ? '✓ válido' : '✕ inválido'} + + {:else} + + ⚠ error + + {/if} +
+ + +
+ + + + + +
+ + {#if store.activePanel === 'editor'} +
+

+ Editor de atributo +

+ +
+ + {:else if store.activePanel === 'runtime'} +
+

+ Runtime — prueba el motor +

+ +
+ + {:else if store.activePanel === 'graph'} +
+

+ Grafo de dependencias +

+ +
+ + {:else if store.activePanel === 'json'} +
+

+ Cat JSON +

+
+                        {JSON.stringify(store.cat, null, 2)}
+                    
+
+ {/if} + +
+ +
+
diff --git a/src/vcen/editor/comps/demos/ConditionBuilderDemo.svelte b/src/vcen/editor/comps/demos/ConditionBuilderDemo.svelte new file mode 100644 index 0000000..cfa6140 --- /dev/null +++ b/src/vcen/editor/comps/demos/ConditionBuilderDemo.svelte @@ -0,0 +1,159 @@ + + + + +
+
+ + +
+
+

ConditionBuilder

+

Demo — {obj.name}

+
+
+ + {isValid ? '✓ válida' : '✕ inválida'} + + +
+
+ + +
+ + +
+ +
+ + +
+ + +
+

Variables

+
+ {#each store.scope as entry (entry.path)} +
+ {entry.path} + + {entry.domain.kind} + +
+ {/each} +
+
+ + +
+

JsonLogicValue

+
{JSON.stringify(compiled, null, 2)}
+
+ + + {#if store.errors.length > 0} +
+

Errores

+
+ {#each store.errors as error} +

• {error.message}

+ {/each} +
+
+ {/if} + +
+
+
+
\ No newline at end of file diff --git a/src/vcen/editor/comps/demos/GraphPanel.svelte b/src/vcen/editor/comps/demos/GraphPanel.svelte new file mode 100644 index 0000000..2e6ccb8 --- /dev/null +++ b/src/vcen/editor/comps/demos/GraphPanel.svelte @@ -0,0 +1,147 @@ + + +{#if !graph} +
+ ⚠ {store.graphError ?? 'Grafo no disponible'} +
+{:else if atts.length === 0} +

Añade atributos para ver el grafo

+{:else} +
+ +

+ Pasa el cursor sobre un atributo para ver sus dependencias +

+ + {#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} + +
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'}" + > +
+
+ + {att.name} + + {att.id} +
+
+ {#if deps.length > 0} + + depende de {deps.length} + + {/if} + {#if affected.length > 0} + + afecta a {affected.length} + + {/if} + {#if deps.length === 0 && affected.length === 0} + + independiente + + {/if} +
+
+ + {#if isHovered} +
+ {#if deps.length > 0} +
+ ← depende de: + {#each deps as depId} + + {attName(depId)} + + {/each} +
+ {/if} + {#if affected.length > 0} +
+ → afecta a: + {#each affected as affId} + + {attName(affId)} + + {/each} +
+ {/if} + {#if att.rules?.rules?.length} +
+ {att.rules.rules.length} regla{att.rules.rules.length > 1 ? 's' : ''} +
+ {/if} +
+ {/if} + +
+ {/each} + + +
+
+
+ seleccionado +
+
+
+ input (del que depende) +
+
+
+ afectado +
+
+ +
+{/if} diff --git a/src/vcen/editor/comps/demos/RuntimePanel.svelte b/src/vcen/editor/comps/demos/RuntimePanel.svelte new file mode 100644 index 0000000..33d5aa2 --- /dev/null +++ b/src/vcen/editor/comps/demos/RuntimePanel.svelte @@ -0,0 +1,167 @@ + + +{#if !session || !result} +
+ ⚠ {store.graphError ?? 'Error al crear la sesión'} +
+{:else} +
+ + +
+ + {result.valid ? '✓ Configuración válida' : '✕ Configuración inválida'} + + +
+ + + {#each atts as att (att.id)} + {@const es = getEs(att.id)} + {@const currentValue = getValue(att.id)} + {@const domain = att.value.domain} + +
+ + +
+
+ {att.name} + {att.id} +
+ {#if es} + + {es.state} + + {/if} +
+ + + {#if es?.state !== 'hidden' && es?.state !== 'forbidden'} +
+ + {#if domain.kind === 'boolean'} + + + {:else if domain.kind === 'option'} + + + {:else if domain.kind === 'number' || domain.kind === 'currency'} + 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'} + 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'} + 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} + 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} + + ⟵ derivado: {JSON.stringify(es.derivedValue.value)} + + {/if} + +
+ {:else if es?.state === 'forbidden'} +

Este atributo no está disponible

+ {/if} + + + {#if es?.messages?.length} + {#each es.messages as msg} +

+ {msg.level === 'error' ? '✕' : '⚠'} {msg.message} +

+ {/each} + {/if} + +
+ {/each} + +
+{/if} \ No newline at end of file diff --git a/src/vcen/editor/comps/demos/demo.store.svelte.ts b/src/vcen/editor/comps/demos/demo.store.svelte.ts new file mode 100644 index 0000000..f95872e --- /dev/null +++ b/src/vcen/editor/comps/demos/demo.store.svelte.ts @@ -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(makeFreshCat()); + + // Runtime session — se reconstruye cuando cambia el cat + let session = $state(null); + let graphError = $state(null); + + // Grafo para visualización + let graph = $state(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(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; + +// ─── 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), + })), + })), + }; +} \ No newline at end of file diff --git a/src/vcen/editor/lib/condition_editor.ts b/src/vcen/editor/lib/condition_editor.ts new file mode 100644 index 0000000..404ddfb --- /dev/null +++ b/src/vcen/editor/lib/condition_editor.ts @@ -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 = { + 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; +} \ No newline at end of file diff --git a/src/vcen/editor/main.ts b/src/vcen/editor/main.ts new file mode 100644 index 0000000..33227e6 --- /dev/null +++ b/src/vcen/editor/main.ts @@ -0,0 +1,11 @@ + +import { mount } from 'svelte'; +import App from './App.svelte'; + +const app = mount(App, { + target: document.getElementById('app')! +}); + + + +export default app; diff --git a/src/vcen/editor/stores/ConditionEditor.svelte.ts b/src/vcen/editor/stores/ConditionEditor.svelte.ts new file mode 100644 index 0000000..2b36585 --- /dev/null +++ b/src/vcen/editor/stores/ConditionEditor.svelte.ts @@ -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 + * + * + * {#if store.tautology.kind === 'always-true'} + * Esta condición siempre es verdadera + * {/if} + * + * + * ``` + */ +export function createConditionBuilderStore( + container: Obj | Sec, + initial?: JsonLogicValue, +) { + // ── Estado reactivo ─────────────────────────────────────────────────────── + + let root = $state( + initial + ? parseCondition(initial) + : makeLogicNode('and', []) + ); + + const scope = $derived(buildScope(container)); + + const errors = $derived( + validateCondition(root, scope) + ); + + const tautology = $derived(detectTautology(root)); + + const compiled = $derived( + 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; + +// ───────────────────────────────────────────────────────────────────────────── +// 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; +} diff --git a/src/vcen/editor/tests/condition_editor.test.ts b/src/vcen/editor/tests/condition_editor.test.ts new file mode 100644 index 0000000..62dd5af --- /dev/null +++ b/src/vcen/editor/tests/condition_editor.test.ts @@ -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); + }); +}); diff --git a/src/vcen/engine.ts b/src/vcen/lib/engine.ts similarity index 76% rename from src/vcen/engine.ts rename to src/vcen/lib/engine.ts index af7553b..ed92917 100644 --- a/src/vcen/engine.ts +++ b/src/vcen/lib/engine.ts @@ -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 { 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(); let prevSnapshot = ''; let iterations = 0; @@ -456,7 +586,7 @@ export function evaluateCat( const nextEffective = new Map(); 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; -} \ No newline at end of file +} diff --git a/src/vcen/graph.ts b/src/vcen/lib/graph.ts similarity index 99% rename from src/vcen/graph.ts rename to src/vcen/lib/graph.ts index ff80f82..f740feb 100644 --- a/src/vcen/graph.ts +++ b/src/vcen/lib/graph.ts @@ -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"; diff --git a/src/vcen/incremental.ts b/src/vcen/lib/incremental.ts similarity index 76% rename from src/vcen/incremental.ts rename to src/vcen/lib/incremental.ts index d37fa5f..087255c 100644 --- a/src/vcen/incremental.ts +++ b/src/vcen/lib/incremental.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(); 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 { - 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 `${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; +} \ No newline at end of file diff --git a/src/vcen/node_util.ts b/src/vcen/lib/node_util.ts similarity index 66% rename from src/vcen/node_util.ts rename to src/vcen/lib/node_util.ts index b514202..57bb2b9 100644 --- a/src/vcen/node_util.ts +++ b/src/vcen/lib/node_util.ts @@ -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 { - const state: Record = {}; - - const addAtt = (prefix: string, att: Att) => { - state[joinPath(prefix, att.id)] = att.value.value; - }; - - for (const att of cat.atts) { - addAtt(cat.id, att); - } - - for (const obj of cat.objs) { - const objPrefix = joinPath(cat.id, obj.id); - for (const att of obj.atts) { - addAtt(objPrefix, att); - } - for (const sec of obj.secs) { - const secPrefix = joinPath(objPrefix, sec.id); - for (const att of sec.atts) { - addAtt(secPrefix, att); - } - } - } - - return state; -} \ No newline at end of file diff --git a/src/vcen/session.ts b/src/vcen/lib/session.ts similarity index 95% rename from src/vcen/session.ts rename to src/vcen/lib/session.ts index 88c16e6..96539a1 100644 --- a/src/vcen/session.ts +++ b/src/vcen/lib/session.ts @@ -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"; diff --git a/src/vcen/types/att.ts b/src/vcen/lib/types/att.ts similarity index 73% rename from src/vcen/types/att.ts rename to src/vcen/lib/types/att.ts index 3ea717f..9b4427d 100644 --- a/src/vcen/types/att.ts +++ b/src/vcen/lib/types/att.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; + /** * 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. diff --git a/src/vcen/types/cat.ts b/src/vcen/lib/types/cat.ts similarity index 53% rename from src/vcen/types/cat.ts rename to src/vcen/lib/types/cat.ts index 2f64856..a050f04 100644 --- a/src/vcen/types/cat.ts +++ b/src/vcen/lib/types/cat.ts @@ -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; + /** * 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[]; } diff --git a/src/vcen/lib/types/engine.ts b/src/vcen/lib/types/engine.ts new file mode 100644 index 0000000..7282143 --- /dev/null +++ b/src/vcen/lib/types/engine.ts @@ -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; + + diff --git a/src/vcen/types/graph.ts b/src/vcen/lib/types/graph.ts similarity index 83% rename from src/vcen/types/graph.ts rename to src/vcen/lib/types/graph.ts index 9bbfb71..61ab7e3 100644 --- a/src/vcen/types/graph.ts +++ b/src/vcen/lib/types/graph.ts @@ -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 } diff --git a/src/vcen/types/incremental.ts b/src/vcen/lib/types/incremental.ts similarity index 91% rename from src/vcen/types/incremental.ts rename to src/vcen/lib/types/incremental.ts index aac857d..3de86ea 100644 --- a/src/vcen/types/incremental.ts +++ b/src/vcen/lib/types/incremental.ts @@ -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. diff --git a/src/vcen/lib/types/index.ts b/src/vcen/lib/types/index.ts new file mode 100644 index 0000000..2870611 --- /dev/null +++ b/src/vcen/lib/types/index.ts @@ -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' + diff --git a/src/vcen/lib/types/metadata.ts b/src/vcen/lib/types/metadata.ts new file mode 100644 index 0000000..9bf2d4d --- /dev/null +++ b/src/vcen/lib/types/metadata.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; \ No newline at end of file diff --git a/src/vcen/types/node.ts b/src/vcen/lib/types/node.ts similarity index 52% rename from src/vcen/types/node.ts rename to src/vcen/lib/types/node.ts index 02a4010..773d172 100644 --- a/src/vcen/types/node.ts +++ b/src/vcen/lib/types/node.ts @@ -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; -export type ObjID = ID; -export type SecID = ID; -export type AttID = ID; -export type OptID = ID; /** Cualquier entidad que puede participar en el grafo o en un path */ diff --git a/src/vcen/types/obj.ts b/src/vcen/lib/types/obj.ts similarity index 66% rename from src/vcen/types/obj.ts rename to src/vcen/lib/types/obj.ts index 98d32bb..01d4a3d 100644 --- a/src/vcen/types/obj.ts +++ b/src/vcen/lib/types/obj.ts @@ -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; /** * Object — entidad configurable dentro de un Catalog. diff --git a/src/vcen/lib/types/opt.ts b/src/vcen/lib/types/opt.ts new file mode 100644 index 0000000..c850464 --- /dev/null +++ b/src/vcen/lib/types/opt.ts @@ -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; + +// ───────────────────────────────────────────────────────────────────────────── +// IDENTITY +// ───────────────────────────────────────────────────────────────────────────── + +export const optIDPrefix = 'op' as const; +export type OptID = ID; + +/** + * 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; +} + diff --git a/src/vcen/types/rule.ts b/src/vcen/lib/types/rule.ts similarity index 93% rename from src/vcen/types/rule.ts rename to src/vcen/lib/types/rule.ts index 51e39eb..5def30f 100644 --- a/src/vcen/types/rule.ts +++ b/src/vcen/lib/types/rule.ts @@ -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; - - -// ───────────────────────────────────────────────────────────────────────────── -// 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' } /** diff --git a/src/vcen/types/sec.ts b/src/vcen/lib/types/sec.ts similarity index 63% rename from src/vcen/types/sec.ts rename to src/vcen/lib/types/sec.ts index 93e57ba..6114fec 100644 --- a/src/vcen/types/sec.ts +++ b/src/vcen/lib/types/sec.ts @@ -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; + -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. diff --git a/src/vcen/types/session.ts b/src/vcen/lib/types/session.ts similarity index 89% rename from src/vcen/types/session.ts rename to src/vcen/lib/types/session.ts index 1d937bb..6aec5cd 100644 --- a/src/vcen/types/session.ts +++ b/src/vcen/lib/types/session.ts @@ -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; diff --git a/src/vcen/types/validation.ts b/src/vcen/lib/types/validation.ts similarity index 91% rename from src/vcen/types/validation.ts rename to src/vcen/lib/types/validation.ts index 3efb363..84e9694 100644 --- a/src/vcen/types/validation.ts +++ b/src/vcen/lib/types/validation.ts @@ -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 } diff --git a/src/vcen/validation.ts b/src/vcen/lib/validation.ts similarity index 94% rename from src/vcen/validation.ts rename to src/vcen/lib/validation.ts index 19d6d64..104bbd0 100644 --- a/src/vcen/validation.ts +++ b/src/vcen/lib/validation.ts @@ -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); -} \ No newline at end of file +} diff --git a/src/vcen/test/domain-validation.test.ts b/src/vcen/test/domain-validation.test.ts index 412f1f0..0df6c62 100644 --- a/src/vcen/test/domain-validation.test.ts +++ b/src/vcen/test/domain-validation.test.ts @@ -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')); }); }); diff --git a/src/vcen/test/engine.test.ts b/src/vcen/test/engine.test.ts index 3724b56..7d00f65 100644 --- a/src/vcen/test/engine.test.ts +++ b/src/vcen/test/engine.test.ts @@ -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); }); -}); \ No newline at end of file +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 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'); + }); +}); diff --git a/src/vcen/test/graph.test.ts b/src/vcen/test/graph.test.ts index 2059c8b..87c841a 100644 --- a/src/vcen/test/graph.test.ts +++ b/src/vcen/test/graph.test.ts @@ -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); }); -}); \ No newline at end of file +}); diff --git a/src/vcen/test/incremental.test.ts b/src/vcen/test/incremental.test.ts index 7126732..96f6d20 100644 --- a/src/vcen/test/incremental.test.ts +++ b/src/vcen/test/incremental.test.ts @@ -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', [ diff --git a/src/vcen/test/session.test.ts b/src/vcen/test/session.test.ts index 0f8f0a4..5358504 100644 --- a/src/vcen/test/session.test.ts +++ b/src/vcen/test/session.test.ts @@ -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'), diff --git a/src/vcen/types/index.ts b/src/vcen/types/index.ts deleted file mode 100644 index dac4260..0000000 --- a/src/vcen/types/index.ts +++ /dev/null @@ -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' -