diff --git a/src/vcen-edit/comps/AttDetail.svelte b/src/vcen-edit/comps/AttDetail.svelte index dd040d1..eee1e61 100644 --- a/src/vcen-edit/comps/AttDetail.svelte +++ b/src/vcen-edit/comps/AttDetail.svelte @@ -1,4 +1,4 @@ - @@ -123,19 +134,35 @@ const ATT_STATES: { state: AttState; label: string; color: string }[] = [
- Cuando se cumple - - -
- {#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() + }) +})