/** * Tests permanentes del motor de simulación corriendo sobre los 3 sample BPMNs. * Protegen contra regresiones en runSimulation, la propagación de probabilidades * y la detección de loops. * * Costos dummy predecibles para que las aserciones sean deterministas. */ import { describe, it, expect } from 'vitest' import { readFileSync } from 'fs' import { resolve } from 'path' import { v4 as uuidv4 } from 'uuid' import { parseBpmnXml, extractActivityElements, extractGatewayElements } from '@/domain/bpmn-parser' import { runSimulation } from '@/domain/simulation' import { bpmnTypeToGatewayType } from '@/domain/types' import type { Activity, GatewayConfig, Resource, SimulationInput } from '@/domain/types' function loadSample(name: string): string { return readFileSync(resolve(__dirname, '../../public/sample-processes', name), 'utf-8') } // Construye un SimulationInput con costos fijos iguales para todas las actividades function buildInput( xml: string, opts: { directCostFixed?: number executionTimeMinutes?: number overheadPercentage?: number gatewayProbsOverride?: Record } = {} ): SimulationInput { const { directCostFixed = 100, executionTimeMinutes = 30, overheadPercentage = 0.2, gatewayProbsOverride = {}, } = opts const processId = 'sim-test' const graph = parseBpmnXml(xml) const actEls = extractActivityElements(graph) const gwEls = extractGatewayElements(graph) const activities = new Map( actEls.map((el) => [ el.bpmnElementId, { id: uuidv4(), processId, bpmnElementId: el.bpmnElementId, name: el.name, type: el.type, directCostFixed, executionTimeMinutes, assignedResources: [], automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [], }, ]) ) const gateways = new Map( gwEls.map((el) => { const gwType = bpmnTypeToGatewayType(el.gatewayType as any) const n = el.outgoing.length const probsOverride = gatewayProbsOverride[el.bpmnElementId] return [ el.bpmnElementId, { id: uuidv4(), processId, bpmnElementId: el.bpmnElementId, gatewayType: gwType, branches: el.outgoing.map((flowId, i) => ({ flowId, targetElementId: graph.flows.get(flowId)?.targetRef ?? '', probability: probsOverride?.[i] ?? (gwType === 'parallel' ? 1.0 : parseFloat((1 / n).toFixed(6))), })), }, ] }) ) return { processXml: xml, activities, gateways, resources: new Map(), globalSettings: { currency: 'USD', overheadPercentage }, } } // ─── simple-linear.bpmn ────────────────────────────────────────────────────── describe('simulación / simple-linear.bpmn', () => { const xml = loadSample('simple-linear.bpmn') it('corre sin lanzar error con costos dummy', () => { expect(() => runSimulation(buildInput(xml))).not.toThrow() }) it('no genera warnings (proceso sin loops ni gateways)', () => { const result = runSimulation(buildInput(xml)) expect(result.warnings).toHaveLength(0) }) it('costo total = 5 × 100 × 1.2 = 600 (5 tareas, prob=1, overhead=20%)', () => { const result = runSimulation(buildInput(xml, { directCostFixed: 100, overheadPercentage: 0.2 })) expect(result.totalDirectCost).toBe(500) expect(result.totalIndirectCost).toBeCloseTo(100) expect(result.totalCost).toBeCloseTo(600) }) it('perActivity tiene 5 entradas, todas con executionProbability = 1', () => { const result = runSimulation(buildInput(xml)) expect(result.perActivity).toHaveLength(5) result.perActivity.forEach((a) => { expect(a.executionProbability).toBeCloseTo(1.0) }) }) it('perActivity ordenado descendente por expectedTotalCost', () => { const result = runSimulation(buildInput(xml, { directCostFixed: 100 })) for (let i = 0; i < result.perActivity.length - 1; i++) { expect(result.perActivity[i].expectedTotalCost).toBeGreaterThanOrEqual( result.perActivity[i + 1].expectedTotalCost ) } }) it('percentOfTotal suma exactamente 100%', () => { const result = runSimulation(buildInput(xml)) const sum = result.perActivity.reduce((acc, a) => acc + a.percentOfTotal, 0) expect(sum).toBeCloseTo(100, 1) }) it('tiempo total = 5 × 30 min = 150 min', () => { const result = runSimulation(buildInput(xml, { executionTimeMinutes: 30 })) expect(result.totalTimeMinutes).toBeCloseTo(150) }) }) // ─── medium-with-gateways.bpmn ─────────────────────────────────────────────── describe('simulación / medium-with-gateways.bpmn', () => { const xml = loadSample('medium-with-gateways.bpmn') it('corre sin lanzar error con costos dummy', () => { expect(() => runSimulation(buildInput(xml))).not.toThrow() }) it('no genera warnings (sin loops)', () => { const result = runSimulation(buildInput(xml)) expect(result.warnings).toHaveLength(0) }) it('perActivity tiene exactamente 11 entradas', () => { const result = runSimulation(buildInput(xml)) expect(result.perActivity).toHaveLength(11) }) it('actividades antes de cualquier gateway tienen prob = 1.0', () => { const result = runSimulation(buildInput(xml)) const recv = result.perActivity.find((a) => a.bpmnElementId === 'task_recv') const valid = result.perActivity.find((a) => a.bpmnElementId === 'task_valid') expect(recv?.executionProbability).toBeCloseTo(1.0) expect(valid?.executionProbability).toBeCloseTo(1.0) }) it('gateway XOR con probs 0.7/0.3 propaga correctamente', () => { // gw_datos: completo(idx 0)=0.7, incompleto(idx 1)=0.3 const result = runSimulation( buildInput(xml, { gatewayProbsOverride: { gw_datos: [0.7, 0.3] }, }) ) const score = result.perActivity.find((a) => a.bpmnElementId === 'task_score') const pedirDocs = result.perActivity.find((a) => a.bpmnElementId === 'task_pedir_docs') expect(score?.executionProbability).toBeCloseTo(0.7, 1) expect(pedirDocs?.executionProbability).toBeCloseTo(0.3, 1) }) it('gateway paralelo AND: ambas ramas tienen prob igual a la entrada', () => { // gw_datos completo=1.0 para simplificar, gw_parallel_split AND ambas =1 const result = runSimulation( buildInput(xml, { gatewayProbsOverride: { gw_datos: [1.0, 0.0], // siempre el camino completo gw_decision: [0.5, 0.5], }, }) ) const bureau = result.perActivity.find((a) => a.bpmnElementId === 'task_bureau') const empleador = result.perActivity.find((a) => a.bpmnElementId === 'task_empleador') // Ambas ramas del AND deben tener prob similar (≈ prob del score) expect(bureau?.executionProbability).toBeGreaterThan(0) expect(empleador?.executionProbability).toBeGreaterThan(0) expect(Math.abs((bureau?.executionProbability ?? 0) - (empleador?.executionProbability ?? 0))).toBeLessThan(0.01) }) it('perActivity ordenado descendente por costo', () => { const result = runSimulation(buildInput(xml)) for (let i = 0; i < result.perActivity.length - 1; i++) { expect(result.perActivity[i].expectedTotalCost).toBeGreaterThanOrEqual( result.perActivity[i + 1].expectedTotalCost ) } }) }) // ─── complex-with-loop.bpmn ─────────────────────────────────────────────────── describe('simulación / complex-with-loop.bpmn', () => { const xml = loadSample('complex-with-loop.bpmn') it('corre sin lanzar error (a pesar del loop)', () => { expect(() => runSimulation(buildInput(xml))).not.toThrow() }) it('🚨 genera al menos un warning de loop detectado', () => { const result = runSimulation(buildInput(xml)) expect(result.warnings.length).toBeGreaterThan(0) const hasLoopWarning = result.warnings.some( (w) => w.toLowerCase().includes('loop') || w.toLowerCase().includes('ciclo') ) expect(hasLoopWarning).toBe(true) }) it('el warning menciona el nodo "Inspección visual y funcional"', () => { const result = runSimulation(buildInput(xml)) const loopWarning = result.warnings.find( (w) => w.toLowerCase().includes('loop') || w.toLowerCase().includes('ciclo') ) expect(loopWarning).toContain('Inspección visual y funcional') }) it('resultado es válido a pesar del loop: costo > 0 y 9 actividades', () => { const result = runSimulation(buildInput(xml, { directCostFixed: 100 })) expect(result.totalCost).toBeGreaterThan(0) expect(result.perActivity).toHaveLength(9) }) it('con probabilidades reales (60% aprobado, 30% correcciones, 10% rechazo)', () => { const result = runSimulation( buildInput(xml, { directCostFixed: 200, overheadPercentage: 0.15, gatewayProbsOverride: { // gw_resultado: 3 salidas — aprobado 0.6, obs_menores 0.3, rechazo 0.1 gw_resultado: [0.6, 0.3, 0.1], }, }) ) expect(result.totalCost).toBeGreaterThan(0) expect(result.warnings.length).toBeGreaterThan(0) // task_inspeccion tiene prob alta (es el nodo más costoso del proceso con loop) const inspeccion = result.perActivity.find((a) => a.bpmnElementId === 'task_inspeccion') expect(inspeccion).toBeDefined() expect(inspeccion!.executionProbability).toBeGreaterThan(0) // task_correcciones tiene prob = prob del camino de correcciones ≈ 0.3 const correcciones = result.perActivity.find((a) => a.bpmnElementId === 'task_correcciones') expect(correcciones).toBeDefined() expect(correcciones!.executionProbability).toBeCloseTo(0.3, 1) // task_rechazo tiene prob ≈ 0.1 const rechazo = result.perActivity.find((a) => a.bpmnElementId === 'task_rechazo') expect(rechazo).toBeDefined() expect(rechazo!.executionProbability).toBeCloseTo(0.1, 1) }) it('percentOfTotal suma ≈ 100%', () => { const result = runSimulation(buildInput(xml)) if (result.perActivity.length > 0) { const sum = result.perActivity.reduce((acc, a) => acc + a.percentOfTotal, 0) expect(sum).toBeCloseTo(100, 0) } }) })