/** * 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( actIds.map((id) => [id, { id: uuidv4(), processId, bpmnElementId: id, name: id, type: 'task', directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [], }]) ) const gateways = new Map([ ['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() }) })