- {#each ATT_STATES as s}
-
- {/each}
+
+
+
Si se cumple →
+
+ {#each ATT_STATES as s}
+
+ {/each}
+
+
+
+
+
Si no se cumple →
+
+ {#each ATT_STATES as s}
+
+ {/each}
+
diff --git a/src/vcen-edit/comps/catalog-editor.svelte.ts b/src/vcen-edit/comps/catalog-editor.svelte.ts
index 7f5fb6f..33b0f16 100644
--- a/src/vcen-edit/comps/catalog-editor.svelte.ts
+++ b/src/vcen-edit/comps/catalog-editor.svelte.ts
@@ -4,6 +4,9 @@ import type { Cat, Obj, Sec, Att, AttDomain, AttValue,
RuleID, AttState, RelativePath,
DomainValueRef, DomainValueState,
RuleLevel, CatID, ObjID, SecID, AttID } from '@/vcen/lib';
+import { toAttID, toObjID, toOptID, toRuleID, toSecID } from '@/vcen/lib/node_util';
+import { catOptIDPrefix } from '@/vcen/lib/types/opt';
+import { buildEvalCtx, evalRuleSetState } from '@/vcen-edit/lib/rule-eval.ts';
// ─────────────────────────────────────────────────────────────────────────────
// SELECTION — nodo activo en el árbol
@@ -28,9 +31,7 @@ export type Selection =
// ─────────────────────────────────────────────────────────────────────────────
let _seq = Date.now();
-function nextId
(prefix: string): T {
- return `${prefix}:${(++_seq).toString(36)}` as T;
-}
+function nextSeq(): string { return (++_seq).toString(36); }
// ─────────────────────────────────────────────────────────────────────────────
// DEFAULT FACTORIES
@@ -41,34 +42,34 @@ function defaultAttValue(): AttValue {
}
function defaultAtt(name = 'Nuevo atributo', slug?: string): Att {
- const id = slug ? `at:${slug}` as AttID : nextId('at');
+ const id = toAttID(slug ?? nextSeq());
return { id, name, value: defaultAttValue() };
}
function defaultSec(name = 'Nueva sección', slug?: string): Sec {
- const id = slug ? `sc:${slug}` as SecID : nextId('sc');
+ const id = toSecID(slug ?? nextSeq());
return { id, name, atts: [] };
}
function defaultObj(name = 'Nuevo objeto', slug?: string): Obj {
- const id = slug ? `ob:${slug}` as ObjID : nextId('ob');
+ const id = toObjID(slug ?? nextSeq());
return { id, name, atts: [], secs: [] };
}
function defaultOpt(value = ''): Opt {
return {
- id: nextId('op'),
+ id: toOptID(nextSeq()),
value: { kind: 'string', value },
};
}
function defaultOptCategory(name = 'Nueva categoría'): OptCategory {
- return { id: nextId('oc'), name };
+ return { id: `${catOptIDPrefix}_${nextSeq()}` as OptCatID, name };
}
function defaultRule(): Rule {
return {
- id: nextId('rl'),
+ id: toRuleID(nextSeq()),
priority: 10,
condition: true,
actions: [{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }],
@@ -101,12 +102,63 @@ export function createCatalogEditorStore(initial: Cat) {
let cat = $state(JSON.parse(JSON.stringify(initial)));
let selection = $state({ kind: 'cat-info' });
let dirty = $state(false);
+ let rev = $state(0); // incrementa en cada mutación — fuerza reactividad global
+
+ // Estados calculados por reglas.
+ // Usamos $effect + $state en lugar de $derived.by para garantizar que
+ // el efecto se re-ejecuta ante cualquier cambio profundo de `cat`
+ // (incluyendo att.value.value dentro de buildEvalCtx).
+ let objStates = $state(new Map())
+ let secStates = $state(new Map())
+
+ /** Valor efectivo de un atributo (mode:fixed → domain.fixed, resto → value.value). */
+ function attVal(att: Att): unknown {
+ const d = att.value.domain as any
+ return d?.mode === 'fixed' ? (d.fixed ?? att.value.value) : att.value.value
+ }
+
+ $effect(() => {
+ const catCtx: Record = {}
+ for (const att of cat.atts) catCtx[att.id] = attVal(att)
+
+ const m = new Map()
+ for (const obj of cat.objs) {
+ if (!obj.rules?.rules?.length) { m.set(obj.id, null); continue }
+ const ctx: Record = { cat: catCtx }
+ for (const a of obj.atts) ctx[a.id] = attVal(a)
+ for (const sec of obj.secs) {
+ ctx[sec.id] = {}
+ for (const a of sec.atts) ctx[sec.id][a.id] = attVal(a)
+ }
+ m.set(obj.id, evalRuleSetState(obj.rules, ctx))
+ }
+ objStates = m
+ })
+
+ $effect(() => {
+ const catCtx: Record = {}
+ for (const att of cat.atts) catCtx[att.id] = attVal(att)
+
+ const m = new Map()
+ for (const obj of cat.objs) {
+ for (const sec of obj.secs) {
+ if (!sec.rules?.rules?.length) { m.set(sec.id, null); continue }
+ const ctx: Record = { cat: catCtx }
+ for (const a of obj.atts) ctx[a.id] = attVal(a)
+ ctx[sec.id] = {}
+ for (const a of sec.atts) ctx[sec.id][a.id] = attVal(a)
+ m.set(sec.id, evalRuleSetState(sec.rules, ctx))
+ }
+ }
+ secStates = m
+ })
// ── Helpers internos ────────────────────────────────────────────────────
function mutate(fn: () => void) {
fn();
dirty = true;
+ rev++;
}
function findObj(objId: ObjID): Obj | undefined {
@@ -539,15 +591,15 @@ export function createCatalogEditorStore(initial: Cat) {
// ── Slug validators ─────────────────────────────────────────────────────
function isObjSlugTaken(slug: string): boolean {
- return cat.objs.some(o => o.id === `ob:${slug}`);
+ return cat.objs.some(o => o.id === toObjID(slug));
}
function isSecSlugTaken(slug: string): boolean {
- return cat.objs.some(o => o.secs.some(s => s.id === `sc:${slug}`));
+ return cat.objs.some(o => o.secs.some(s => s.id === toSecID(slug)));
}
function isAttSlugTaken(slug: string): boolean {
- const id = `at:${slug}`;
+ const id = toAttID(slug);
if (cat.atts.some(a => a.id === id)) return true;
return cat.objs.some(o =>
o.atts.some(a => a.id === id) ||
@@ -585,6 +637,9 @@ export function createCatalogEditorStore(initial: Cat) {
get cat() { return cat; },
get selection() { return selection; },
get dirty() { return dirty; },
+ get rev() { return rev; },
+ get objStates() { return objStates; },
+ get secStates() { return secStates; },
get activeOptId() { return activeOptId; },
get activeOpt() { return activeOptId ? cat.opts.find(o => o.id === activeOptId) ?? null : null; },
setActiveOpt(id: string) { activeOptId = id; },
diff --git a/src/vcen-edit/lib/rule-eval.ts b/src/vcen-edit/lib/rule-eval.ts
index 9124c02..98cc58c 100644
--- a/src/vcen-edit/lib/rule-eval.ts
+++ b/src/vcen-edit/lib/rule-eval.ts
@@ -16,23 +16,31 @@ const _engine = new JsonLogicEngine()
* → atributo directo del contenedor
* / → atributo de sección (solo si container es Obj)
*/
+/** Valor efectivo de un atributo para evaluar condiciones.
+ * En modo 'fixed' el valor real está en domain.fixed, no en att.value.value. */
+function attCtxValue(att: { value: { domain: any; value: unknown } }): unknown {
+ const d = att.value.domain
+ if (d?.mode === 'fixed') return d.fixed ?? att.value.value
+ return att.value.value
+}
+
export function buildEvalCtx(cat: Cat, container: Obj | Sec): Record {
const ctx: Record = {}
ctx['cat'] = {}
for (const att of cat.atts ?? []) {
- ctx['cat'][att.id] = att.value.value
+ ctx['cat'][att.id] = attCtxValue(att)
}
for (const att of container.atts) {
- ctx[att.id] = att.value.value
+ ctx[att.id] = attCtxValue(att)
}
if ('secs' in container) {
for (const sec of (container as Obj).secs) {
ctx[sec.id] = {}
for (const att of sec.atts) {
- ctx[sec.id][att.id] = att.value.value
+ ctx[sec.id][att.id] = attCtxValue(att)
}
}
}
@@ -47,8 +55,10 @@ export function evalRuleSetState(
): AttState | null {
if (!ruleSet?.rules.length) return null
- // Mayor prioridad primero (número mayor = se evalúa antes)
- const sorted = [...ruleSet.rules].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
+ // Menor número = mayor prioridad (se evalúa antes)
+ const sorted = [...ruleSet.rules].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
+
+ let fallback: AttState | null = null
for (const rule of sorted) {
let fires: boolean
@@ -58,14 +68,15 @@ export function evalRuleSetState(
: Boolean(_engine.evaluate(rule.condition as any, ctx))
} catch { continue }
- if (!fires) continue
+ const action = rule.actions.find(
+ a => a.type === 'attribute' && (!a.target || a.target.length === 0)
+ )
+ if (!action) continue
- for (const action of rule.actions) {
- if (action.type === 'attribute' && (!action.target || action.target.length === 0)) {
- return action.ifTrue
- }
- }
+ if (fires) return action.ifTrue
+
+ if (fallback === null) fallback = (action as any).ifFalse ?? null
}
- return null
+ return fallback
}
diff --git a/src/vcen-edit/tests/rule-eval.test.ts b/src/vcen-edit/tests/rule-eval.test.ts
new file mode 100644
index 0000000..81ffb26
--- /dev/null
+++ b/src/vcen-edit/tests/rule-eval.test.ts
@@ -0,0 +1,198 @@
+import { describe, it, expect } from 'vitest'
+import { buildEvalCtx, evalRuleSetState } from '../lib/rule-eval'
+import type { Cat, Obj, RuleSet } from '@/vcen/lib'
+import { toAttID, toObjID, toSecID, toRuleID } from '@/vcen/lib/node_util'
+
+// ─── helpers ────────────────────────────────────────────────────────────────
+
+function catAtt(slug: string, value: unknown) {
+ return { id: toAttID(slug), name: slug, value: { domain: { kind: 'string', mode: 'input' } as any, value } }
+}
+
+function objAtt(slug: string, value: unknown) {
+ return { id: toAttID(slug), name: slug, value: { domain: { kind: 'string', mode: 'input' } as any, value } }
+}
+
+function emptyCat(atts: ReturnType[] = []): Cat {
+ return { id: toObjID('test') as any, name: 'Test', atts, objs: [], opts: [], optCategories: [] }
+}
+
+function emptyObj(atts: ReturnType[] = [], secs: any[] = []): Obj {
+ return { id: toObjID('obj'), name: 'Obj', atts, secs }
+}
+
+function rule(condition: any, ifTrue: string, ifFalse = 'available', priority = 10) {
+ return {
+ id: toRuleID('r1'),
+ priority,
+ condition,
+ actions: [{ type: 'attribute' as const, target: [] as any, ifTrue: ifTrue as any, ifFalse: ifFalse as any }],
+ }
+}
+
+function ruleSet(...rules: ReturnType[]): RuleSet {
+ return { rules }
+}
+
+// ─── buildEvalCtx ───────────────────────────────────────────────────────────
+
+describe('buildEvalCtx', () => {
+
+ it('pone atributos de catálogo bajo ctx.cat[attId]', () => {
+ const cat = emptyCat([catAtt('pais', 'es'), catAtt('lang', 'es')])
+ const ctx = buildEvalCtx(cat, emptyObj())
+ expect(ctx.cat[toAttID('pais')]).toBe('es')
+ expect(ctx.cat[toAttID('lang')]).toBe('es')
+ })
+
+ it('ctx.cat está vacío si el catálogo no tiene atributos', () => {
+ const ctx = buildEvalCtx(emptyCat(), emptyObj())
+ expect(ctx.cat).toEqual({})
+ })
+
+ it('pone atributos del contenedor directamente en ctx', () => {
+ const obj = emptyObj([objAtt('color', 'rojo')])
+ const ctx = buildEvalCtx(emptyCat(), obj)
+ expect(ctx[toAttID('color')]).toBe('rojo')
+ })
+
+ it('pone atributos de sección bajo ctx[secId][attId]', () => {
+ const sec = { id: toSecID('motor'), name: 'Motor', atts: [objAtt('fuel', 'electrico')] }
+ const obj = emptyObj([], [sec])
+ const ctx = buildEvalCtx(emptyCat(), obj)
+ expect(ctx[toSecID('motor')][toAttID('fuel')]).toBe('electrico')
+ })
+
+ it('combina cat, obj y secciones correctamente', () => {
+ const cat = emptyCat([catAtt('pais', 'es')])
+ const sec = { id: toSecID('motor'), name: 'Motor', atts: [objAtt('fuel', 'electrico')] }
+ const obj = emptyObj([objAtt('color', 'rojo')], [sec])
+ const ctx = buildEvalCtx(cat, obj)
+ expect(ctx.cat[toAttID('pais')]).toBe('es')
+ expect(ctx[toAttID('color')]).toBe('rojo')
+ expect(ctx[toSecID('motor')][toAttID('fuel')]).toBe('electrico')
+ })
+
+ it('refleja el valor actual del atributo (no hardcodeado)', () => {
+ const cat = emptyCat([catAtt('pais', 'fr')])
+ const ctx = buildEvalCtx(cat, emptyObj())
+ expect(ctx.cat[toAttID('pais')]).toBe('fr')
+ })
+})
+
+// ─── evalRuleSetState ───────────────────────────────────────────────────────
+
+describe('evalRuleSetState', () => {
+
+ it('devuelve null si no hay ruleSet', () => {
+ expect(evalRuleSetState(undefined, {})).toBeNull()
+ })
+
+ it('devuelve null si el ruleSet está vacío', () => {
+ expect(evalRuleSetState({ rules: [] }, {})).toBeNull()
+ })
+
+ it('devuelve ifTrue cuando condition === true (literal)', () => {
+ expect(evalRuleSetState(ruleSet(rule(true, 'hidden')), {})).toBe('hidden')
+ })
+
+ it('devuelve ifFalse cuando condition === false (literal)', () => {
+ expect(evalRuleSetState(ruleSet(rule(false, 'hidden', 'available')), {})).toBe('available')
+ })
+
+ it('devuelve ifTrue cuando la condición JsonLogic se cumple', () => {
+ const ctx = { cat: { [toAttID('pais')]: 'es' } }
+ const cond = { '==': [{ var: `cat/${toAttID('pais')}` }, 'es'] }
+ expect(evalRuleSetState(ruleSet(rule(cond, 'hidden', 'available')), ctx)).toBe('hidden')
+ })
+
+ it('devuelve ifFalse cuando la condición JsonLogic no se cumple', () => {
+ const ctx = { cat: { [toAttID('pais')]: 'fr' } }
+ const cond = { '==': [{ var: `cat/${toAttID('pais')}` }, 'es'] }
+ expect(evalRuleSetState(ruleSet(rule(cond, 'hidden', 'available')), ctx)).toBe('available')
+ })
+
+ it('cambia de hidden a available al cambiar el valor del atributo', () => {
+ const cond = { '==': [{ var: `cat/${toAttID('pais')}` }, 'es'] }
+ const rs = ruleSet(rule(cond, 'hidden', 'available'))
+
+ expect(evalRuleSetState(rs, { cat: { [toAttID('pais')]: 'es' } })).toBe('hidden')
+ expect(evalRuleSetState(rs, { cat: { [toAttID('pais')]: 'fr' } })).toBe('available')
+ })
+
+ it('prioridad: menor número se evalúa primero', () => {
+ // regla p=5 (mayor prioridad) dispara → hidden
+ // regla p=10 también dispara → available — pero p=5 gana
+ const rs = ruleSet(
+ rule(true, 'available', 'available', 10),
+ rule(true, 'hidden', 'available', 5),
+ )
+ expect(evalRuleSetState(rs, {})).toBe('hidden')
+ })
+
+ it('si la regla de mayor prioridad no dispara, evalúa la siguiente', () => {
+ const condFalse = { '==': [{ var: 'x' }, 'never'] }
+ const rs = ruleSet(
+ rule(condFalse, 'hidden', 'available', 5), // no dispara
+ rule(true, 'required', 'available', 10), // dispara
+ )
+ // p=5 no dispara (ifFalse='available' como fallback)
+ // p=10 dispara → required, pero fallback ya tiene 'available' de p=5
+ // la primera regla que DISPARA gana: p=10 → required
+ expect(evalRuleSetState(rs, {})).toBe('required')
+ })
+
+ it('usa buildEvalCtx para evaluar contra atributos de catálogo', () => {
+ const cat = emptyCat([catAtt('pais', 'es')])
+ const obj = emptyObj()
+ const ctx = buildEvalCtx(cat, obj)
+ const cond = { '==': [{ var: `cat/${toAttID('pais')}` }, 'es'] }
+ const rs = ruleSet(rule(cond, 'hidden', 'available'))
+ expect(evalRuleSetState(rs, ctx)).toBe('hidden')
+ })
+
+ it('usa domain.fixed como valor cuando mode=fixed (no att.value.value)', () => {
+ // Reproduce el bug: at_pais con mode:fixed fixed:"es" y value.value:""
+ const att = {
+ id: toAttID('pais'), name: 'pais',
+ value: { domain: { kind: 'string' as const, mode: 'fixed' as any, fixed: 'es' }, value: '' }
+ }
+ const cat = emptyCat([att])
+ const ctx = buildEvalCtx(cat, emptyObj())
+ // El contexto debe contener 'es', no ''
+ expect(ctx.cat[toAttID('pais')]).toBe('es')
+
+ const cond = { and: [{ '==': [{ var: `cat/${toAttID('pais')}` }, 'es'] }] }
+ expect(evalRuleSetState(ruleSet(rule(cond, 'available', 'forbidden')), ctx)).toBe('available')
+ })
+
+ // Condición con wrapper { and: [...] } — formato real del ConditionBuilder
+ it('condición wrapeada en and: ifTrue cuando se cumple', () => {
+ const ctx = { cat: { [toAttID('pais')]: 'es' } }
+ const cond = { and: [{ '==': [{ var: `cat/${toAttID('pais')}` }, 'es'] }] }
+ expect(evalRuleSetState(ruleSet(rule(cond, 'forbidden', 'available')), ctx)).toBe('forbidden')
+ })
+
+ it('condición wrapeada en and: ifFalse cuando NO se cumple', () => {
+ const ctx = { cat: { [toAttID('pais')]: 'fr' } }
+ const cond = { and: [{ '==': [{ var: `cat/${toAttID('pais')}` }, 'es'] }] }
+ expect(evalRuleSetState(ruleSet(rule(cond, 'forbidden', 'available')), ctx)).toBe('available')
+ })
+
+ it('condición != wrapeada en and: ifTrue cuando NO se cumple el ==', () => {
+ const ctx = { cat: { [toAttID('pais')]: 'es' } }
+ // at_pais != 'es' → FALSE cuando pais='es' → devuelve ifFalse
+ const cond = { and: [{ '!=': [{ var: `cat/${toAttID('pais')}` }, 'es'] }] }
+ expect(evalRuleSetState(ruleSet(rule(cond, 'forbidden', 'available')), ctx)).toBe('available')
+ })
+
+ it('devuelve null si ninguna regla tiene acción de tipo attribute sin target', () => {
+ const rs: RuleSet = {
+ rules: [{
+ id: toRuleID('r1'), priority: 10, condition: true,
+ actions: [{ type: 'attribute', target: [toAttID('otro')] as any, ifTrue: 'hidden', ifFalse: 'available' }]
+ }]
+ }
+ expect(evalRuleSetState(rs, {})).toBeNull()
+ })
+})