- rule-eval: ordenación por prioridad corregida (ascendente: menor nº = mayor prioridad)
- rule-eval: atributos mode:fixed ahora usan domain.fixed como valor efectivo (no att.value.value vacío)
- catalog-editor: estados obj/sec calculados via $effect+$state con contexto inline para tracking reactivo garantizado
- CatalogSidebar: consume objStates/secStates del store en lugar de derivados locales
- ObjInfoPanel: usa editor.objStates en lugar de buildEvalCtx local
- RuleEditor: añade edición de ifFalse ("si no se cumple") con colores por estado
- AttDetail: oculta "valor por defecto" en mode:fixed; label "Valor fijo" para el input fixed
- Tests: suite completa para buildEvalCtx y evalRuleSetState (21 tests)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
parent
2301149e58
commit
e792cd7e1d
@ -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<typeof catAtt>[] = []): Cat {
|
||||
return { id: toObjID('test') as any, name: 'Test', atts, objs: [], opts: [], optCategories: [] }
|
||||
}
|
||||
|
||||
function emptyObj(atts: ReturnType<typeof objAtt>[] = [], 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<typeof rule>[]): 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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in new issue