Antes de la última revisión de etapa 4. Sprint 1. Previo al ajuste de look n feel de app, reportes, nombre y colores.
This commit is contained in:
232
tests/integration/sprint1-flow.test.ts
Normal file
232
tests/integration/sprint1-flow.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user