Files
inq-roi-simulador-web/tests/integration/sprint1-flow.test.ts

233 lines
9.8 KiB
TypeScript

/**
* 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<string, Activity> {
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,
}])
)
}
function buildGateways(xml: string): Map<string, GatewayConfig> {
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<SimulationInput, 'scenario'> = {
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)
}
})
})