Files
inq-roi-simulador-web/tests/integration/gateway-validation.test.ts

159 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Tests permanentes de validación de gateways.
* Protegen las reglas de negocio: XOR debe sumar 1, AND no tiene probs editables.
*/
import { describe, it, expect } from 'vitest'
import { isXorSumValid } from '@/features/workspace/GatewayPanel'
import { runSimulation } from '@/domain/simulation'
import type { GatewayConfig, SimulationInput, Activity } from '@/domain/types'
import { v4 as uuidv4 } from 'uuid'
import { readFileSync } from 'fs'
import { resolve } from 'path'
function makeBranches(probs: number[]): GatewayConfig['branches'] {
return probs.map((p, i) => ({ flowId: `f${i}`, targetElementId: `t${i}`, probability: p }))
}
function makeXorGateway(probs: number[]): GatewayConfig {
return {
id: uuidv4(), processId: 'p', bpmnElementId: 'gw1',
gatewayType: 'exclusive',
branches: makeBranches(probs),
}
}
function makeParallelGateway(n: number): GatewayConfig {
return {
id: uuidv4(), processId: 'p', bpmnElementId: 'gw1',
gatewayType: 'parallel',
branches: makeBranches(Array(n).fill(1.0)),
}
}
// ─── isXorSumValid ────────────────────────────────────────────────────────────
describe('isXorSumValid', () => {
it('válido: suma exactamente 1.0', () => {
expect(isXorSumValid(makeXorGateway([0.6, 0.4]))).toBe(true)
})
it('válido: suma = 1.0 con 3 ramas (0.5 + 0.3 + 0.2)', () => {
expect(isXorSumValid(makeXorGateway([0.5, 0.3, 0.2]))).toBe(true)
})
it('válido: tolerancia de 0.5% — 0.999 se acepta como 1.0', () => {
expect(isXorSumValid(makeXorGateway([0.499, 0.501]))).toBe(true)
})
it('inválido: suma = 0.5 (faltan 50%)', () => {
expect(isXorSumValid(makeXorGateway([0.3, 0.2]))).toBe(false)
})
it('inválido: suma = 1.5 (excede 100%)', () => {
expect(isXorSumValid(makeXorGateway([0.8, 0.7]))).toBe(false)
})
it('inválido: suma = 0 (gateways sin configurar)', () => {
expect(isXorSumValid(makeXorGateway([0, 0]))).toBe(false)
})
it('gateway paralelo siempre válido (no aplica regla de suma)', () => {
expect(isXorSumValid(makeParallelGateway(2))).toBe(true)
expect(isXorSumValid(makeParallelGateway(3))).toBe(true)
})
it('gateway inclusivo tratado como exclusivo — aplica regla de suma', () => {
const gw: GatewayConfig = { ...makeXorGateway([0.6, 0.4]), gatewayType: 'inclusive' }
// inclusive usa isXorSumValid solo para el display; el motor lo trata igual
// Aquí verificamos que la función no lanza para gateways inclusive
expect(() => isXorSumValid(gw)).not.toThrow()
})
})
// ─── Motor: gateways con distribución 70/30 sobre medium BPMN ────────────────
describe('motor / propagación con GatewayConfig correcta', () => {
const xml = readFileSync(resolve(__dirname, '../../public/sample-processes/medium-with-gateways.bpmn'), 'utf-8')
function buildSimInput(datosProbs: [number, number], decisionProbs: [number, number]): SimulationInput {
const processId = 'gv-test'
const actIds = ['task_recv', 'task_valid', 'task_pedir_docs', 'task_score',
'task_bureau', 'task_empleador', 'task_analisis',
'task_aprobar', 'task_rechazar', 'task_desembolso', 'task_archivo']
const activities = new Map<string, Activity>(
actIds.map((id) => [id, {
id: uuidv4(), processId, bpmnElementId: id, name: id,
type: 'task', directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
}])
)
const gateways = new Map<string, GatewayConfig>([
['gw_datos', {
id: uuidv4(), processId, bpmnElementId: 'gw_datos', gatewayType: 'exclusive',
branches: [
{ flowId: 'flow_gw1_completo', targetElementId: 'task_score', probability: datosProbs[0] },
{ flowId: 'flow_gw1_incompleto', targetElementId: 'task_pedir_docs', probability: datosProbs[1] },
],
}],
['gw_parallel_split', {
id: uuidv4(), processId, bpmnElementId: 'gw_parallel_split', gatewayType: 'parallel',
branches: [
{ flowId: 'flow_para_bureau', targetElementId: 'task_bureau', probability: 1.0 },
{ flowId: 'flow_para_empleador', targetElementId: 'task_empleador', probability: 1.0 },
],
}],
['gw_decision', {
id: uuidv4(), processId, bpmnElementId: 'gw_decision', gatewayType: 'exclusive',
branches: [
{ flowId: 'flow_dec_aprobado', targetElementId: 'task_aprobar', probability: decisionProbs[0] },
{ flowId: 'flow_dec_rechazado', targetElementId: 'task_rechazar', probability: decisionProbs[1] },
],
}],
])
return { processXml: xml, activities, gateways, resources: new Map(), globalSettings: { currency: 'USD', overheadPercentage: 0 } }
}
it('rama "completo" 70% → task_score tiene prob ≈ 0.7', () => {
const result = runSimulation(buildSimInput([0.7, 0.3], [0.8, 0.2]))
const score = result.perActivity.find((a) => a.bpmnElementId === 'task_score')
expect(score?.executionProbability).toBeCloseTo(0.7, 2)
})
it('rama "incompleto" 30% → task_pedir_docs tiene prob ≈ 0.3', () => {
const result = runSimulation(buildSimInput([0.7, 0.3], [0.8, 0.2]))
const pedir = result.perActivity.find((a) => a.bpmnElementId === 'task_pedir_docs')
expect(pedir?.executionProbability).toBeCloseTo(0.3, 2)
})
it('task_aprobar tiene prob = 0.8 (task_analisis converge ambos caminos a 1.0, luego 80% decision)', () => {
// task_analisis recibe:
// - camino completo (70%): AND join correcto → 0.7
// - camino incompleto (30%): task_pedir_docs → 0.3
// - suma XOR: 0.7 + 0.3 = 1.0
// gw_decision → task_aprobar: 1.0 × 0.8 = 0.8
const result = runSimulation(buildSimInput([0.7, 0.3], [0.8, 0.2]))
const analisis = result.perActivity.find((a) => a.bpmnElementId === 'task_analisis')
const aprobar = result.perActivity.find((a) => a.bpmnElementId === 'task_aprobar')
const rechazar = result.perActivity.find((a) => a.bpmnElementId === 'task_rechazar')
expect(analisis?.executionProbability).toBeCloseTo(1.0, 2)
expect(aprobar?.executionProbability).toBeCloseTo(0.8, 2)
expect(rechazar?.executionProbability).toBeCloseTo(0.2, 2)
})
it('gateway AND: bureau y empleador tienen la misma prob que score', () => {
const result = runSimulation(buildSimInput([1.0, 0.0], [0.5, 0.5]))
const bureau = result.perActivity.find((a) => a.bpmnElementId === 'task_bureau')
const empleador = result.perActivity.find((a) => a.bpmnElementId === 'task_empleador')
// Ambos deben tener la misma probabilidad (AND paralelo)
expect(bureau?.executionProbability).toBeCloseTo(empleador?.executionProbability ?? -1, 2)
})
it('con probs que no suman 1 el motor no explota, solo produce resultados parciales', () => {
// Esto valida que el motor es robusto aunque la UI deba prevenirlo
expect(() => runSimulation(buildSimInput([0.3, 0.3], [0.5, 0.5]))).not.toThrow()
})
})