/** * Test integrador del flujo completo del Sprint 1. * * Verifica que las piezas del dominio conectan correctamente: * parseBpmnXml → extractActivityElements → runSimulation (actual + automated) * → calculateRoi → KPIs correctos * * BPMN base: medium-with-gateways (Aprobación de crédito) * — 11 actividades, 3 gateways (2 XOR + 1 AND), 5 flujos condicionales * * Costos usados (deliberadamente simples para assertions deterministas): * - Todas las actividades: directCostFixed=$200, executionTimeMinutes=60 * - Automatable: solo task_recv y task_score (las más caras por prob alta) * - automatedCostFixed=$20 (90% de ahorro), automatedTimeMinutes=5 * - annualFrequency=1200 (100 ejecuciones/mes), horizonte=3 años, inversión=$50.000 */ 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 { bpmnTypeToGatewayType } from '@/domain/types' import type { Activity, GatewayConfig, SimulationInput } from '@/domain/types' const MEDIUM_XML = readFileSync( resolve(__dirname, '../../public/sample-processes/medium-with-gateways.bpmn'), 'utf-8' ) // Actividades automatable y sus costos automatizados const AUTOMATABLE_IDS = new Set(['task_recv', 'task_score']) const AUTOMATED_COST = 20 const AUTOMATED_TIME = 5 function buildActivities(xml: string): Map { const graph = parseBpmnXml(xml) const elements = extractActivityElements(graph) return new Map( elements.map((el) => [el.bpmnElementId, { id: uuidv4(), processId: 'sprint1-test', bpmnElementId: el.bpmnElementId, name: el.name, type: el.type, directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [], automatable: AUTOMATABLE_IDS.has(el.bpmnElementId), automatedCostFixed: AUTOMATABLE_IDS.has(el.bpmnElementId) ? AUTOMATED_COST : 0, automatedTimeMinutes: AUTOMATABLE_IDS.has(el.bpmnElementId) ? AUTOMATED_TIME : 0, automatedAssignedResources: [], }]) ) } function buildGateways(xml: string): Map { const graph = parseBpmnXml(xml) const elements = extractGatewayElements(graph) return new Map( elements.map((el) => { const gwType = bpmnTypeToGatewayType(el.gatewayType as any) const n = el.outgoing.length return [el.bpmnElementId, { id: uuidv4(), processId: 'sprint1-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 BASE_INPUT: Omit = { processXml: MEDIUM_XML, activities: buildActivities(MEDIUM_XML), gateways: buildGateways(MEDIUM_XML), resources: new Map(), globalSettings: { currency: 'USD', overheadPercentage: 0.2 }, } const VOLUMETRY = { annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000, } // ─── Tests ──────────────────────────────────────────────────────────────────── describe('Sprint 1 — flujo integrado: simulación dual + ROI', () => { const resultActual = runSimulation({ ...BASE_INPUT, scenario: 'actual' }) const resultAutomated = runSimulation({ ...BASE_INPUT, scenario: 'automated' }) // ─── Simulación actual ────────────────────────────────────────────────── it('simulación actual produce resultado no nulo con actividades reales', () => { expect(resultActual.totalCost).toBeGreaterThan(0) expect(resultActual.perActivity.length).toBeGreaterThan(0) expect(resultActual.warnings).toBeDefined() }) it('simulación actual: task_recv tiene probabilidad 1.0 (primer nodo del proceso)', () => { const recv = resultActual.perActivity.find((a) => a.bpmnElementId === 'task_recv') expect(recv).toBeDefined() expect(recv!.executionProbability).toBeCloseTo(1.0) }) it('simulación actual: las actividades con costos tienen expectedDirectCost > 0', () => { for (const act of resultActual.perActivity) { expect(Number.isNaN(act.expectedDirectCost)).toBe(false) expect(act.expectedDirectCost).toBeGreaterThanOrEqual(0) } }) // ─── Simulación automatizada ──────────────────────────────────────────── it('simulación automatizada produce totalCost menor que actual', () => { // Hay actividades automatable con costo menor → total debe bajar expect(resultAutomated.totalCost).toBeLessThan(resultActual.totalCost) }) it('probabilidades de ejecución son idénticas entre escenarios', () => { 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) } }) it('actividades automatable tienen expectedDirectCost menor en escenario automatizado', () => { for (const bpmnElementId of AUTOMATABLE_IDS) { const actual = resultActual.perActivity.find((a) => a.bpmnElementId === bpmnElementId) const automated = resultAutomated.perActivity.find((a) => a.bpmnElementId === bpmnElementId) if (!actual || !automated) continue // podría no aparecer si prob=0 expect(automated.expectedDirectCost).toBeLessThan(actual.expectedDirectCost) } }) it('actividades no automatable tienen el mismo costo en ambos escenarios', () => { for (const act of resultActual.perActivity) { if (AUTOMATABLE_IDS.has(act.bpmnElementId)) continue const auto = resultAutomated.perActivity.find((a) => a.bpmnElementId === act.bpmnElementId)! expect(auto.expectedDirectCost).toBeCloseTo(act.expectedDirectCost, 5) } }) it('ningún campo numérico del resultado automatizado es NaN', () => { expect(Number.isNaN(resultAutomated.totalCost)).toBe(false) expect(Number.isNaN(resultAutomated.totalDirectCost)).toBe(false) expect(Number.isNaN(resultAutomated.totalIndirectCost)).toBe(false) for (const act of resultAutomated.perActivity) { expect(Number.isNaN(act.expectedDirectCost)).toBe(false) expect(Number.isNaN(act.expectedTotalCost)).toBe(false) expect(Number.isNaN(act.percentOfTotal)).toBe(false) } }) // ─── calculateRoi conecta con los resultados de simulación ───────────── it('calculateRoi recibe costos de ambas simulaciones y devuelve KPIs válidos', () => { const roi = calculateRoi({ costPerExecutionActual: resultActual.totalCost, costPerExecutionAutomated: resultAutomated.totalCost, ...VOLUMETRY, }) // Ahorro positivo (automated < actual) expect(roi.savingsPerExecution).toBeGreaterThan(0) expect(roi.annualSavings).toBeGreaterThan(0) // Payback finito y positivo expect(roi.paybackMonths).toBeGreaterThan(0) expect(Number.isFinite(roi.paybackMonths)).toBe(true) // Con inversión $50.000 y ahorro anual razonable, debería pagar dentro del horizonte de 3 años // (esto depende de los costos calculados, pero verificamos que es un número razonable) expect(roi.paybackYears).toBeGreaterThan(0) // ROI anualizado positivo (ahorro > 0, inversión > 0) expect(roi.roiAnnualPercent).toBeGreaterThan(0) expect(Number.isNaN(roi.roiAnnualPercent)).toBe(false) expect(Number.isNaN(roi.cumulativeSavingsHorizon)).toBe(false) }) it('ahorro acumulado a 3 años supera la inversión si hay ahorro positivo suficiente', () => { const roi = calculateRoi({ costPerExecutionActual: resultActual.totalCost, costPerExecutionAutomated: resultAutomated.totalCost, ...VOLUMETRY, }) if (roi.annualSavings * VOLUMETRY.analysisHorizonYears > VOLUMETRY.automationInvestment) { expect(roi.cumulativeSavingsHorizon).toBeGreaterThan(0) expect(roi.breaksEvenInHorizon).toBe(true) } else { // Si el ahorro es menor que la inversión en 3 años, el test es informativo expect(roi.cumulativeSavingsHorizon).toBeDefined() } }) // ─── Transparencia metodológica: actividades automatable con costo cero ─ it('detecta actividades automatable con automatedCostFixed=0 para transparencia', () => { // En nuestro fixture solo task_recv y task_score son automatable con costo > 0. // Si alguien marca una actividad como automatable pero no pone costo, // el resultado automatizado de esa actividad debe tener cost=0. const activities = buildActivities(MEDIUM_XML) // Forzar una actividad automatable con costo cero (simula campo no configurado) const taskValid = activities.get('task_valid') if (taskValid) { activities.set('task_valid', { ...taskValid, automatable: true, automatedCostFixed: 0, automatedTimeMinutes: 0 }) } const result = runSimulation({ ...BASE_INPUT, activities, scenario: 'automated' }) const validResult = result.perActivity.find((a) => a.bpmnElementId === 'task_valid') if (validResult && validResult.executionProbability > 0) { // automatable=true pero costos en 0 → contribuye con $0 al total automatizado expect(validResult.expectedDirectCost).toBe(0) expect(Number.isNaN(validResult.expectedDirectCost)).toBe(false) } }) })