fix(vcen-edit): corrección evaluador de reglas y reactividad del estado

- 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
dev 7 months ago
parent 2301149e58
commit e792cd7e1d

@ -1,4 +1,4 @@
<script lang="ts">
un <script lang="ts">
import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
import type { AttDomain, ObjID, SecID, AttID } from '@/vcen/lib'
@ -130,6 +130,7 @@ function updateDomain(domain: AttDomain) {
// Computed state from rules
const computedState = $derived.by(() => {
void editor.rev
if (!ctx?.att.rules) return null
// For cat-level atts, use the cat itself as container proxy
const container = ctx.container ?? (ctx.isCatLevel ? { atts: editor.cat.atts ?? [], id: editor.cat.id } as any : null)
@ -231,10 +232,13 @@ const stateLabel: Record<string, string> = {
{ kind: 'string', mode: 'pattern', pattern: e.currentTarget.value })}
placeholder="^[A-Z]{2}[0-9]+$" class="field-input" />
{:else if d.mode === 'fixed'}
<input type="text" value={d.fixed ?? ''}
oninput={e => updateDomain(
{ kind: 'string', mode: 'fixed', fixed: e.currentTarget.value })}
class="field-input" />
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">Valor fijo</label>
<input type="text" value={d.fixed ?? ''}
oninput={e => updateDomain(
{ kind: 'string', mode: 'fixed', fixed: e.currentTarget.value })}
class="field-input" />
</div>
{/if}
</div>
@ -383,6 +387,7 @@ const stateLabel: Record<string, string> = {
</section>
<!-- Valor por defecto -->
{#if (att.value.domain as any).mode !== 'fixed'}
<section class="flex flex-col gap-3">
<h3 class="section-title">Valor por defecto</h3>
@ -442,6 +447,7 @@ const stateLabel: Record<string, string> = {
class="field-input" />
{/if}
</section>
{/if}
<!-- Reglas -->
<section class="flex flex-col gap-3">

@ -7,6 +7,16 @@ const editor = getContext<CatalogEditorStore>('catalog-editor')
const cat = $derived(editor.cat)
const sel = $derived(editor.selection)
const objStates = $derived(editor.objStates)
const secStates = $derived(editor.secStates)
const STATE_DOT: Record<string, string> = {
available: 'bg-green-500',
required: 'bg-red-500',
hidden: 'bg-gray-500',
forbidden: 'bg-orange-500',
}
// Open/closed state for objects and sections
let openObjs = $state<Set<ObjID>>(new Set())
let openSecs = $state<Set<SecID>>(new Set())
@ -85,7 +95,7 @@ const ITEM_INACTIVE = "text-gray-500 hover:text-gray-200 hover:bg-gray-800/50 bo
<!-- ── CATÁLOGO ──────────────────────────────────────────────── -->
<div class="px-3 py-1.5 mb-0.5">
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-700">Catálogo</span>
<span class="text-[10npm run dpx] font-bold uppercase tracking-widest text-gray-700">Catálogo</span>
</div>
<!-- Información general -->
@ -186,6 +196,7 @@ const ITEM_INACTIVE = "text-gray-500 hover:text-gray-200 hover:bg-gray-800/50 bo
{@const isObjOpen = openObjs.has(obj.id)}
{@const objAttCount = obj.atts.length}
{@const secCount = obj.secs.length}
{@const objState = objStates.get(obj.id) ?? null}
<!-- Obj row -->
<div
@ -204,6 +215,9 @@ const ITEM_INACTIVE = "text-gray-500 hover:text-gray-200 hover:bg-gray-800/50 bo
<rect x="1" y="1" width="10" height="10" rx="2"/>
</svg>
<span class="flex-1 truncate font-medium">{obj.name || 'Sin nombre'}</span>
{#if objState}
<span class="h-1.5 w-1.5 rounded-full shrink-0 {STATE_DOT[objState] ?? 'bg-gray-600'}" title={objState}></span>
{/if}
<button
onclick={e => { e.stopPropagation(); editor.removeObj(obj.id) }}
class="opacity-0 group-hover:opacity-100 text-gray-700 hover:text-red-400 transition-opacity ml-auto"
@ -297,6 +311,7 @@ const ITEM_INACTIVE = "text-gray-500 hover:text-gray-200 hover:bg-gray-800/50 bo
{#each obj.secs as sec (sec.id)}
{@const isSecOpen = openSecs.has(sec.id)}
{@const secAttCount = sec.atts.length}
{@const secState = secStates.get(sec.id) ?? null}
<!-- Sec row -->
<div
@ -312,6 +327,9 @@ const ITEM_INACTIVE = "text-gray-500 hover:text-gray-200 hover:bg-gray-800/50 bo
viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2"
><path d="M3 2l4 3-4 3"/></svg>
<span class="flex-1 truncate">{sec.name || 'Sin nombre'}</span>
{#if secState}
<span class="h-1.5 w-1.5 rounded-full shrink-0 {STATE_DOT[secState] ?? 'bg-gray-600'}" title={secState}></span>
{/if}
<button
onclick={e => { e.stopPropagation(); editor.removeSec(obj.id, sec.id) }}
class="opacity-0 group-hover:opacity-100 text-gray-700 hover:text-red-400 transition-opacity ml-auto"

@ -2,17 +2,18 @@
import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
import RuleList from './RuleList.svelte'
import { buildEvalCtx, evalRuleSetState } from '@/vcen-edit/lib/rule-eval.ts'
import { evalRuleSetState } from '@/vcen-edit/lib/rule-eval.ts'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const sel = $derived(editor.selection)
const obj = $derived(sel.kind === 'obj-info' ? editor.findObj(sel.objId) ?? null : null)
const objId = $derived(sel.kind === 'obj-info' ? sel.objId : null)
// Usa editor.objStates (calculado en el store via $effect) para garantizar
// que se actualiza al cambiar atributos del catálogo.
const computedState = $derived.by(() => {
if (!obj) return null
const ctx = buildEvalCtx(editor.cat, obj)
return evalRuleSetState(obj.rules, ctx)
return editor.objStates.get(obj.id) ?? null
})
const stateBadge: Record<string, string> = {

@ -55,11 +55,22 @@ const action = $derived.by(() => {
return a as { type: 'attribute'; target: string[]; ifTrue: AttState } | undefined
})
const ifTrue = $derived(action?.ifTrue ?? 'hidden')
const ifTrue = $derived(action?.ifTrue ?? 'hidden')
const ifFalse = $derived((action as any)?.ifFalse ?? 'available')
const target = $derived((action?.target ?? [])[0] ?? '')
function setIfTrue(state: AttState) {
const act = { type: 'attribute' as const, target: action?.target ?? [], ifTrue: state, ifFalse: 'available' as AttState }
const act = { type: 'attribute' as const, target: action?.target ?? [], ifTrue: state, ifFalse: ifFalse as AttState }
if (action) {
const idx = rule.actions.indexOf(action as any)
editor.updateAction(ruleSet, rule.id, idx, act)
} else {
editor.addAction(ruleSet, rule.id, act)
}
}
function setIfFalse(state: AttState) {
const act = { type: 'attribute' as const, target: action?.target ?? [], ifTrue: ifTrue as AttState, ifFalse: state }
if (action) {
const idx = rule.actions.indexOf(action as any)
editor.updateAction(ruleSet, rule.id, idx, act)
@ -78,11 +89,11 @@ function setTarget(targetId: string) {
}
}
const ATT_STATES: { state: AttState; label: string; color: string }[] = [
{ state: 'available', label: 'Disponible', color: 'text-green-400' },
{ state: 'required', label: 'Requerido', color: 'text-blue-400' },
{ state: 'hidden', label: 'Oculto', color: 'text-gray-400' },
{ state: 'forbidden', label: 'Prohibido', color: 'text-red-400' },
const ATT_STATES: { state: AttState; label: string; active: string }[] = [
{ state: 'available', label: 'Disponible', active: 'bg-green-900/40 border-green-700 text-green-400' },
{ state: 'required', label: 'Requerido', active: 'bg-blue-900/40 border-blue-700 text-blue-400' },
{ state: 'hidden', label: 'Oculto', active: 'bg-gray-800 border-gray-600 text-gray-300' },
{ state: 'forbidden', label: 'Prohibido', active: 'bg-red-900/40 border-red-700 text-red-400' },
]
</script>
@ -123,19 +134,35 @@ const ATT_STATES: { state: AttState; label: string; color: string }[] = [
<!-- Resultado -->
<div class="px-3 py-2.5 flex flex-col gap-2">
<span class="text-xs text-gray-600 uppercase tracking-wider">Cuando se cumple</span>
<!-- Estado -->
<div class="flex gap-1.5 flex-wrap">
{#each ATT_STATES as s}
<button
onclick={() => setIfTrue(s.state)}
class="px-2.5 py-1 text-xs rounded border transition-colors
{ifTrue === s.state
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
: `border-gray-700 text-gray-500 hover:border-gray-600 ${s.color}`}"
>{s.label}</button>
{/each}
<div class="flex flex-col gap-1.5">
<span class="text-xs text-gray-600 uppercase tracking-wider">Si se cumple →</span>
<div class="flex gap-1.5 flex-wrap">
{#each ATT_STATES as s}
<button
onclick={() => setIfTrue(s.state)}
class="px-2.5 py-1 text-xs rounded border transition-colors
{ifTrue === s.state
? s.active
: 'border-gray-700 text-gray-500 hover:border-gray-600 hover:text-gray-300'}"
>{s.label}</button>
{/each}
</div>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-xs text-gray-600 uppercase tracking-wider">Si no se cumple →</span>
<div class="flex gap-1.5 flex-wrap">
{#each ATT_STATES as s}
<button
onclick={() => setIfFalse(s.state)}
class="px-2.5 py-1 text-xs rounded border transition-colors
{ifFalse === s.state
? s.active
: 'border-gray-700 text-gray-500 hover:border-gray-600 hover:text-gray-300'}"
>{s.label}</button>
{/each}
</div>
</div>
<!-- Target (solo para reglas de atributo: selfAttId !== undefined) -->

@ -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<T extends string>(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<AttID>('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<SecID>('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<ObjID>('ob');
const id = toObjID(slug ?? nextSeq());
return { id, name, atts: [], secs: [] };
}
function defaultOpt(value = ''): Opt {
return {
id: nextId<OptID>('op'),
id: toOptID(nextSeq()),
value: { kind: 'string', value },
};
}
function defaultOptCategory(name = 'Nueva categoría'): OptCategory {
return { id: nextId<OptCatID>('oc'), name };
return { id: `${catOptIDPrefix}_${nextSeq()}` as OptCatID, name };
}
function defaultRule(): Rule {
return {
id: nextId<RuleID>('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<Cat>(JSON.parse(JSON.stringify(initial)));
let selection = $state<Selection>({ 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<ObjID, AttState | null>())
let secStates = $state(new Map<SecID, AttState | null>())
/** 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<string, unknown> = {}
for (const att of cat.atts) catCtx[att.id] = attVal(att)
const m = new Map<ObjID, AttState | null>()
for (const obj of cat.objs) {
if (!obj.rules?.rules?.length) { m.set(obj.id, null); continue }
const ctx: Record<string, any> = { 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<string, unknown> = {}
for (const att of cat.atts) catCtx[att.id] = attVal(att)
const m = new Map<SecID, AttState | null>()
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<string, any> = { 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; },

@ -16,23 +16,31 @@ const _engine = new JsonLogicEngine()
* <attId> → atributo directo del contenedor
* <secId>/<attId> → 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<string, any> {
const ctx: Record<string, any> = {}
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
}

@ -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…
Cancel
Save

Powered by TurnKey Linux.