/** * Test integrador del flujo ROI completo — Sprint 1 Etapa 3. * * Para cada sample BPMN verifica que el pipeline: * parseBpmnXml → runSimulation (dual) → calculateRoi * produce KPIs numéricos válidos y detecta las condiciones de transparencia * metodológica correctamente. * * Los 4 casos: * A) simple-linear SIN automatizables → automatableIds.size = 0 * B) medium-with-gateways, 3 automatable → KPIs positivos, payback razonable * C) medium-with-gateways, 1 automatable en ceros + investment=0 → transparencia doble * D) complex-with-loop → warnings preservados en ambos escenarios */ 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 { calculateRoi } from '@/domain/roi' import { formatCurrency, formatPercent } from '@/lib/format' import { bpmnTypeToGatewayType } from '@/domain/types' import type { Activity, GatewayConfig, SimulationInput } from '@/domain/types' // ─── Helpers ────────────────────────────────────────────────────────────────── function loadBpmn(name: string): string { return readFileSync(resolve(__dirname, '../../public/sample-processes', name), 'utf-8') } function buildSimInput( xml: string, directCost: number, automatableIds: Set, automatedCost: number, automatedTime: number, ): SimulationInput { const graph = parseBpmnXml(xml) const actEls = extractActivityElements(graph) const gwEls = extractGatewayElements(graph) const processId = 'roi-test' const activities = new Map( actEls.map((el) => [el.bpmnElementId, { id: uuidv4(), processId, bpmnElementId: el.bpmnElementId, name: el.name, type: el.type, directCostFixed: directCost, executionTimeMinutes: 60, assignedResources: [], automatable: automatableIds.has(el.bpmnElementId), automatedCostFixed: automatableIds.has(el.bpmnElementId) ? automatedCost : 0, automatedTimeMinutes: automatableIds.has(el.bpmnElementId) ? automatedTime : 0, }]) ) const gateways = new Map( gwEls.map((el) => { const gwType = bpmnTypeToGatewayType(el.gatewayType as any) const n = el.outgoing.length return [el.bpmnElementId, { id: uuidv4(), processId, bpmnElementId: el.bpmnElementId, gatewayType: gwType, branches: el.outgoing.map((flowId) => ({ flowId, targetElementId: graph.flows.get(flowId)?.targetRef ?? '', probability: gwType === 'parallel' ? 1.0 : parseFloat((1 / n).toFixed(4)), })), }] }) ) return { processXml: xml, activities, gateways, resources: new Map(), globalSettings: { currency: 'USD', overheadPercentage: 0.2 }, } } // Volumetría estándar para todos los tests (la especificada en el brief) const VOL = { annualFrequency: 12_000, analysisHorizonYears: 3, automationInvestment: 50_000 } // ─── CASO A: simple-linear SIN automatizables ───────────────────────────────── describe('Caso A — simple-linear SIN automatizables', () => { const xml = loadBpmn('simple-linear.bpmn') const noAutomatable = new Set() const input = buildSimInput(xml, 200, noAutomatable, 0, 0) const resultActual = runSimulation({ ...input, scenario: 'actual' }) const resultAutomated = runSimulation({ ...input, scenario: 'automated' }) it('simple-linear tiene 5 actividades', () => { const graph = parseBpmnXml(xml) const actEls = extractActivityElements(graph) expect(actEls).toHaveLength(5) }) it('automatableIds.size === 0: ninguna actividad marcada como automatizable', () => { const graph = parseBpmnXml(xml) const actEls = extractActivityElements(graph) const automatableIds = actEls.filter((el) => noAutomatable.has(el.bpmnElementId)) expect(automatableIds).toHaveLength(0) }) it('resultActual y resultAutomated tienen costos idénticos (sin automatizables)', () => { expect(resultActual.totalCost).toBeCloseTo(resultAutomated.totalCost, 4) }) it('ROI con costos idénticos: savingsPerExecution = 0, annualSavings = 0', () => { const roi = calculateRoi({ costPerExecutionActual: resultActual.totalCost, costPerExecutionAutomated: resultAutomated.totalCost, ...VOL, }) expect(roi.savingsPerExecution).toBeCloseTo(0, 4) expect(roi.annualSavings).toBeCloseTo(0, 2) }) it('con ahorro cero: paybackMonths = Infinity', () => { const roi = calculateRoi({ costPerExecutionActual: resultActual.totalCost, costPerExecutionAutomated: resultAutomated.totalCost, ...VOL, }) expect(roi.paybackMonths).toBe(Infinity) }) it('con ahorro cero: breaksEvenInHorizon = false', () => { const roi = calculateRoi({ costPerExecutionActual: resultActual.totalCost, costPerExecutionAutomated: resultAutomated.totalCost, ...VOL, }) expect(roi.breaksEvenInHorizon).toBe(false) }) }) // ─── CASO B: medium-with-gateways con 3 automatizables ─────────────────────── describe('Caso B — medium-with-gateways, 3 automatizables, valores plausibles', () => { const xml = loadBpmn('medium-with-gateways.bpmn') // task_recv (prob≈1.0), task_score (prob≈0.5), task_analisis (prob≈0.5 post-parallel) const automatable3 = new Set(['task_recv', 'task_score', 'task_analisis']) const input = buildSimInput(xml, 200, automatable3, 20, 5) const resultActual = runSimulation({ ...input, scenario: 'actual' }) const resultAutomated = runSimulation({ ...input, scenario: 'automated' }) const roi = calculateRoi({ costPerExecutionActual: resultActual.totalCost, costPerExecutionAutomated: resultAutomated.totalCost, ...VOL, }) it('reporta los KPIs reales del Caso B para inspección manual', () => { // Este test siempre pasa — su propósito es mostrar valores en la consola console.log('\n╔══════════════════════════════════════════════════════════╗') console.log('║ CASO B: medium-with-gateways — KPIs del sistema ║') console.log('╠══════════════════════════════════════════════════════════╣') console.log(`║ Costo actual por ejecución: ${formatCurrency(resultActual.totalCost, 'USD').padEnd(26)} ║`) console.log(`║ Costo automático por ejec.: ${formatCurrency(resultAutomated.totalCost, 'USD').padEnd(26)} ║`) console.log('╠══════════════════════════════════════════════════════════╣') console.log(`║ Ahorro por ejecución: ${formatCurrency(roi.savingsPerExecution, 'USD').padEnd(30)} ║`) console.log(`║ Reducción %: ${formatPercent(roi.savingsPerExecutionPercent).padEnd(30)} ║`) console.log(`║ Ahorro anual: ${formatCurrency(roi.annualSavings, 'USD').padEnd(30)} ║`) console.log(`║ Payback: ${roi.paybackMonths.toFixed(1)} meses`.padEnd(57) + '║') console.log(`║ Acumulado a 3 años: ${formatCurrency(roi.cumulativeSavingsHorizon, 'USD').padEnd(30)} ║`) console.log(`║ ROI anualizado: ${formatPercent(roi.roiAnnualPercent).padEnd(30)} ║`) console.log('╚══════════════════════════════════════════════════════════╝\n') expect(true).toBe(true) }) it('dump de costos por actividad — reconciliación aritmética completa', () => { const autoByElem = new Map(resultAutomated.perActivity.map((a) => [a.bpmnElementId, a])) // Construcción de la tabla en orden de procesamiento topológico const all = resultActual.perActivity .slice() .sort((a, b) => b.executionProbability - a.executionProbability || a.activityName.localeCompare(b.activityName)) const AUTOMATABLE = new Set(['task_recv', 'task_score', 'task_analisis']) let directActualSum = 0 let directAutoSum = 0 console.log('\n┌────────────────────────────────┬──────┬──────────────┬──────────────┐') console.log('│ bpmnElementId │ prob │ directo act. │ directo auto │') console.log('├────────────────────────────────┼──────┼──────────────┼──────────────┤') for (const act of all) { const aAuto = autoByElem.get(act.bpmnElementId) const prob = act.executionProbability const dAct = act.expectedDirectCost const dAuto = aAuto?.expectedDirectCost ?? 0 const auto = AUTOMATABLE.has(act.bpmnElementId) ? '★' : ' ' directActualSum += dAct directAutoSum += dAuto const idPadded = act.bpmnElementId.padEnd(30) const probStr = prob.toFixed(2).padStart(4) const actStr = dAct.toFixed(2).padStart(12) const autoStr = dAuto.toFixed(2).padStart(12) console.log(`│ ${auto}${idPadded} │ ${probStr} │ ${actStr} │ ${autoStr} │`) } console.log('├────────────────────────────────┼──────┼──────────────┼──────────────┤') console.log(`│ SUMA DIRECTA │ │ ${directActualSum.toFixed(2).padStart(12)} │ ${directAutoSum.toFixed(2).padStart(12)} │`) console.log(`│ + overhead 20% │ │ ${(directActualSum * 1.2).toFixed(2).padStart(12)} │ ${(directAutoSum * 1.2).toFixed(2).padStart(12)} │`) console.log('└────────────────────────────────┴──────┴──────────────┴──────────────┘\n') // Verificaciones aritméticas exactas // sum probs de actividades: 1+1+0.5+0.5+0.5+0.5+1+0.5+0.5+0.5+1 = 7.5 const sumProbs = all.reduce((s, a) => s + a.executionProbability, 0) expect(sumProbs).toBeCloseTo(7.5, 4) // Directo actual = 7.5 × $200 = $1.500 expect(directActualSum).toBeCloseTo(1500, 2) // Total con overhead = $1.800 expect(resultActual.totalCost).toBeCloseTo(1800, 2) // Directo automatizado: // task_recv(1×$20)+task_valid(1×$200)+task_score(0.5×$20)+task_pedir_docs(0.5×$200) // +task_bureau(0.5×$200)+task_empleador(0.5×$200)+task_analisis(1×$20) // +task_aprobar(0.5×$200)+task_rechazar(0.5×$200)+task_desembolso(0.5×$200)+task_archivo(1×$200) // = $20+$200+$10+$100+$100+$100+$20+$100+$100+$100+$200 = $1.050 expect(directAutoSum).toBeCloseTo(1050, 2) // Total automatizado con overhead = $1.260 expect(resultAutomated.totalCost).toBeCloseTo(1260, 2) }) it('costAutomated < costActual (hay ahorro)', () => { expect(resultAutomated.totalCost).toBeLessThan(resultActual.totalCost) }) it('savingsPerExecution > 0 (ahorro positivo por ejecución)', () => { expect(roi.savingsPerExecution).toBeGreaterThan(0) }) it('annualSavings > 0 (ahorro anual positivo)', () => { expect(roi.annualSavings).toBeGreaterThan(0) }) it('paybackMonths es finito y positivo', () => { expect(Number.isFinite(roi.paybackMonths)).toBe(true) expect(roi.paybackMonths).toBeGreaterThan(0) }) it('paybackMonths < 24 (recupera la inversión en menos de 2 años con datos plausibles)', () => { expect(roi.paybackMonths).toBeLessThan(24) }) it('ROI anualizado > 100% (inversión se multiplica en el horizonte)', () => { expect(roi.roiAnnualPercent).toBeGreaterThan(100) }) it('breaksEvenInHorizon = true (se recupera dentro de 3 años)', () => { expect(roi.breaksEvenInHorizon).toBe(true) }) it('ningún KPI es NaN', () => { expect(Number.isNaN(roi.savingsPerExecution)).toBe(false) expect(Number.isNaN(roi.annualSavings)).toBe(false) expect(Number.isNaN(roi.cumulativeSavingsHorizon)).toBe(false) expect(Number.isNaN(roi.roiAnnualPercent)).toBe(false) expect(Number.isNaN(roi.paybackMonths)).toBe(false) }) it('tabla de comparación: task_recv tiene ahorro diferente de cero', () => { const actualRecv = resultActual.perActivity.find((a) => a.bpmnElementId === 'task_recv') const autoRecv = resultAutomated.perActivity.find((a) => a.bpmnElementId === 'task_recv') expect(actualRecv).toBeDefined() expect(autoRecv).toBeDefined() expect(autoRecv!.expectedDirectCost).toBeLessThan(actualRecv!.expectedDirectCost) }) it('tabla de comparación: actividades no automatable tienen costos idénticos en ambos escenarios', () => { const nonAutomatableIds = ['task_valid', 'task_bureau', 'task_empleador', 'task_archivo'] for (const id of nonAutomatableIds) { const actual = resultActual.perActivity.find((a) => a.bpmnElementId === id) const auto = resultAutomated.perActivity.find((a) => a.bpmnElementId === id) if (!actual || !auto) continue expect(auto.expectedDirectCost).toBeCloseTo(actual.expectedDirectCost, 4) } }) it('probabilidades de ejecución idénticas entre escenarios (el grafo no cambia)', () => { for (const actActual of resultActual.perActivity) { const actAuto = resultAutomated.perActivity.find( (a) => a.bpmnElementId === actActual.bpmnElementId ) expect(actAuto).toBeDefined() expect(actAuto!.executionProbability).toBeCloseTo(actActual.executionProbability, 5) } }) }) // ─── CASO C: transparencia metodológica (ambas condiciones) ────────────────── describe('Caso C — medium-with-gateways, 1 automatable en ceros + investment=0', () => { const xml = loadBpmn('medium-with-gateways.bpmn') const graph = parseBpmnXml(xml) const actEls = extractActivityElements(graph) const gwEls = extractGatewayElements(graph) // Construir actividades: task_valid marcada como automatable pero con costo=0 y tiempo=0 const activities = new Map( actEls.map((el) => [el.bpmnElementId, { id: uuidv4(), processId: 'c-test', bpmnElementId: el.bpmnElementId, name: el.name, type: el.type, directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [], automatable: el.bpmnElementId === 'task_valid', automatedCostFixed: 0, // cero — trigger de transparencia automatedTimeMinutes: 0, // cero — trigger de transparencia }]) ) const gateways = new Map( gwEls.map((el) => { const gwType = bpmnTypeToGatewayType(el.gatewayType as any) const n = el.outgoing.length return [el.bpmnElementId, { id: uuidv4(), processId: 'c-test', bpmnElementId: el.bpmnElementId, gatewayType: gwType, branches: el.outgoing.map((flowId) => ({ flowId, targetElementId: graph.flows.get(flowId)?.targetRef ?? '', probability: gwType === 'parallel' ? 1.0 : parseFloat((1 / n).toFixed(4)), })), }] }) ) const input: SimulationInput = { processXml: xml, activities, gateways, resources: new Map(), globalSettings: { currency: 'USD', overheadPercentage: 0.2 }, } const resultActual = runSimulation({ ...input, scenario: 'actual' }) const resultAutomated = runSimulation({ ...input, scenario: 'automated' }) // investment=0 para el segundo trigger de transparencia const roi = calculateRoi({ costPerExecutionActual: resultActual.totalCost, costPerExecutionAutomated: resultAutomated.totalCost, annualFrequency: 12_000, analysisHorizonYears: 3, automationInvestment: 0, // ← trigger de transparencia }) it('task_valid automatable con costo=0 → en escenario automatizado, task_valid contribuye $0 de costo directo', () => { const taskValidAuto = resultAutomated.perActivity.find((a) => a.bpmnElementId === 'task_valid') expect(taskValidAuto).toBeDefined() // automatable=true pero costo=0 → contribuye con $0 al directo automatizado expect(taskValidAuto!.expectedDirectCost).toBeCloseTo(0, 4) }) it('TransparencySection Condición 1: hay 1 actividad automatable con costos en cero', () => { const zeroCostAutomatable = actEls.filter((el) => { const act = activities.get(el.bpmnElementId) return act?.automatable && act.automatedCostFixed === 0 && act.automatedTimeMinutes === 0 }) expect(zeroCostAutomatable).toHaveLength(1) expect(zeroCostAutomatable[0].bpmnElementId).toBe('task_valid') }) it('TransparencySection Condición 2: investment=0 → paybackMonths=0 (inmediato)', () => { expect(roi.paybackMonths).toBe(0) }) it('TransparencySection Condición 2: investment=0 → roiAnnualPercent=Infinity', () => { expect(roi.roiAnnualPercent).toBe(Infinity) }) it('ambas condiciones de transparencia coexisten en el mismo proceso', () => { const zeroCostAutomatable = actEls.filter((el) => { const act = activities.get(el.bpmnElementId) return act?.automatable && act.automatedCostFixed === 0 && act.automatedTimeMinutes === 0 }) // Condición 1: al menos 1 automatable con costo=0 expect(zeroCostAutomatable.length).toBeGreaterThan(0) // Condición 2: investment=0 expect(roi.paybackMonths).toBe(0) expect(roi.roiAnnualPercent).toBe(Infinity) }) it('el ahorro sigue siendo positivo aunque task_valid tenga costo=0 en automático (la reducción es real)', () => { // task_valid automatable con costo=0 reduce el total del escenario automatizado expect(resultAutomated.totalCost).toBeLessThan(resultActual.totalCost) expect(roi.savingsPerExecution).toBeGreaterThan(0) }) }) // ─── CASO D: complex-with-loop + warnings ───────────────────────────────────── describe('Caso D — complex-with-loop, warnings preservados en ambos escenarios', () => { const xml = loadBpmn('complex-with-loop.bpmn') const automatable = new Set(['task_preparar', 'task_inspeccion']) const input = buildSimInput(xml, 200, automatable, 25, 5) const resultActual = runSimulation({ ...input, scenario: 'actual' }) const resultAutomated = runSimulation({ ...input, scenario: 'automated' }) it('el escenario actual produce warnings (loop detectado)', () => { expect(resultActual.warnings.length).toBeGreaterThan(0) const loopWarning = resultActual.warnings.some( (w) => w.toLowerCase().includes('loop') || w.toLowerCase().includes('ciclo') || w.toLowerCase().includes('visitad') ) expect(loopWarning).toBe(true) }) it('el escenario automatizado también tiene los mismos warnings de loop', () => { expect(resultAutomated.warnings.length).toBeGreaterThan(0) // La estructura del grafo no cambia entre escenarios → mismo warning expect(resultAutomated.warnings.length).toBe(resultActual.warnings.length) }) it('el tab ROI (cambio de tab) NO silencia el warning — los datos de warnings coexisten', () => { // Este test valida que el SimulationResult preserva el array de warnings // independientemente del escenario. La UI del tab ROI no tiene WarningsBanner // (solo Actual y Automatizado lo tienen), pero los datos existen. expect(resultActual.warnings).toBeDefined() expect(Array.isArray(resultActual.warnings)).toBe(true) expect(resultAutomated.warnings).toBeDefined() expect(Array.isArray(resultAutomated.warnings)).toBe(true) }) it('aún con loop, el totalCost no es NaN', () => { expect(Number.isNaN(resultActual.totalCost)).toBe(false) expect(Number.isNaN(resultAutomated.totalCost)).toBe(false) }) it('aún con loop, la diferencia de costos entre escenarios es real y positiva', () => { expect(resultAutomated.totalCost).toBeLessThan(resultActual.totalCost) }) it('aún con loop, calculateRoi no produce NaN ni errores', () => { const roi = calculateRoi({ costPerExecutionActual: resultActual.totalCost, costPerExecutionAutomated: resultAutomated.totalCost, ...VOL, }) expect(Number.isNaN(roi.savingsPerExecution)).toBe(false) expect(Number.isNaN(roi.annualSavings)).toBe(false) expect(Number.isNaN(roi.cumulativeSavingsHorizon)).toBe(false) expect(roi.savingsPerExecution).toBeGreaterThan(0) }) })