Files
inq-roi-simulador-web/tests/integration/sprint1-report-roi.test.ts
Marcos Benítez 67e259e6ed feat(sprint-3): biblioteca, recursos TO-BE, ROI mejorado e identidad de marca
Sprint 3 completo — 7 etapas:

- Etapa 1-2: biblioteca de procesos con agrupación por cliente (Dexie v4,
  LibraryPage, library-store, groupId/tags en Process)
- Etapa 3: modal de configuración global por proceso (horizonte, frecuencia,
  inversión, moneda) con validación y persistencia
- Etapa 4: panel de recursos rediseñado con soporte de unidades y minutos
  de utilización (utilizationMinutes + units)
- Etapa 5: recursos en escenario automatizado — motor de simulación,
  UI de edición en ActivityPanel, comparación AS-IS/TO-BE en reporte
- Etapa 6: PDF recursos automatizados, automatedAssignedResources requerido,
  561 tests pasando
- Etapa 7: identidad de marca — AppHeader gradiente naranja/magenta con logo
  InQuality en 5 páginas, favicon con logo real, logo/marca navegan a home

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 11:49:43 -03:00

450 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string>,
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<string, Activity>(
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,
automatedAssignedResources: [],
}])
)
const gateways = new Map<string, GatewayConfig>(
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<string>()
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<string, Activity>(
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
automatedAssignedResources: [],
}])
)
const gateways = new Map<string, GatewayConfig>(
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)
})
})