Files
inq-roi-simulador-web/tests/integration/simulation-engine.test.ts
Marcos Benítez bed161c92a feat: MVP Fase 0 — Process Cost Platform v0.1.0
Plataforma completa de análisis de costos operativos basada en BPMN 2.0.

Funcionalidades incluidas:
- Import de archivos BPMN 2.0 por drag & drop + 3 procesos de ejemplo
- Visualización read-only del diagrama (bpmn-js)
- Configuración de actividades (costo, tiempo, recursos asignados)
- CRUD de recursos (rol, persona, sistema, equipo, insumo)
- Configuración global (moneda, overhead %, nombre, cliente)
- Motor de simulación determinístico agregado con propagación de gateways XOR/AND
- Reporte visual con mapa de calor en dos modos (relativo/absoluto)
- Gráficos: KPIs, top actividades, composición, costo por recurso
- Export PDF (jsPDF + html2canvas) y CSV (papaparse)
- Persistencia en IndexedDB (Dexie)
- 268 tests unitarios/integración + 6 E2E con Playwright
- Deploy: https://process-cost-platform.pages.dev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:58:40 -03:00

279 lines
10 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.
/**
* Tests permanentes del motor de simulación corriendo sobre los 3 sample BPMNs.
* Protegen contra regresiones en runSimulation, la propagación de probabilidades
* y la detección de loops.
*
* Costos dummy predecibles para que las aserciones sean deterministas.
*/
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 { bpmnTypeToGatewayType } from '@/domain/types'
import type { Activity, GatewayConfig, Resource, SimulationInput } from '@/domain/types'
function loadSample(name: string): string {
return readFileSync(resolve(__dirname, '../../public/sample-processes', name), 'utf-8')
}
// Construye un SimulationInput con costos fijos iguales para todas las actividades
function buildInput(
xml: string,
opts: {
directCostFixed?: number
executionTimeMinutes?: number
overheadPercentage?: number
gatewayProbsOverride?: Record<string, number[]>
} = {}
): SimulationInput {
const {
directCostFixed = 100,
executionTimeMinutes = 30,
overheadPercentage = 0.2,
gatewayProbsOverride = {},
} = opts
const processId = 'sim-test'
const graph = parseBpmnXml(xml)
const actEls = extractActivityElements(graph)
const gwEls = extractGatewayElements(graph)
const activities = new Map<string, Activity>(
actEls.map((el) => [
el.bpmnElementId,
{
id: uuidv4(),
processId,
bpmnElementId: el.bpmnElementId,
name: el.name,
type: el.type,
directCostFixed,
executionTimeMinutes,
assignedResources: [],
},
])
)
const gateways = new Map<string, GatewayConfig>(
gwEls.map((el) => {
const gwType = bpmnTypeToGatewayType(el.gatewayType as any)
const n = el.outgoing.length
const probsOverride = gatewayProbsOverride[el.bpmnElementId]
return [
el.bpmnElementId,
{
id: uuidv4(),
processId,
bpmnElementId: el.bpmnElementId,
gatewayType: gwType,
branches: el.outgoing.map((flowId, i) => ({
flowId,
targetElementId: graph.flows.get(flowId)?.targetRef ?? '',
probability:
probsOverride?.[i] ??
(gwType === 'parallel' ? 1.0 : parseFloat((1 / n).toFixed(6))),
})),
},
]
})
)
return {
processXml: xml,
activities,
gateways,
resources: new Map<string, Resource>(),
globalSettings: { currency: 'USD', overheadPercentage },
}
}
// ─── simple-linear.bpmn ──────────────────────────────────────────────────────
describe('simulación / simple-linear.bpmn', () => {
const xml = loadSample('simple-linear.bpmn')
it('corre sin lanzar error con costos dummy', () => {
expect(() => runSimulation(buildInput(xml))).not.toThrow()
})
it('no genera warnings (proceso sin loops ni gateways)', () => {
const result = runSimulation(buildInput(xml))
expect(result.warnings).toHaveLength(0)
})
it('costo total = 5 × 100 × 1.2 = 600 (5 tareas, prob=1, overhead=20%)', () => {
const result = runSimulation(buildInput(xml, { directCostFixed: 100, overheadPercentage: 0.2 }))
expect(result.totalDirectCost).toBe(500)
expect(result.totalIndirectCost).toBeCloseTo(100)
expect(result.totalCost).toBeCloseTo(600)
})
it('perActivity tiene 5 entradas, todas con executionProbability = 1', () => {
const result = runSimulation(buildInput(xml))
expect(result.perActivity).toHaveLength(5)
result.perActivity.forEach((a) => {
expect(a.executionProbability).toBeCloseTo(1.0)
})
})
it('perActivity ordenado descendente por expectedTotalCost', () => {
const result = runSimulation(buildInput(xml, { directCostFixed: 100 }))
for (let i = 0; i < result.perActivity.length - 1; i++) {
expect(result.perActivity[i].expectedTotalCost).toBeGreaterThanOrEqual(
result.perActivity[i + 1].expectedTotalCost
)
}
})
it('percentOfTotal suma exactamente 100%', () => {
const result = runSimulation(buildInput(xml))
const sum = result.perActivity.reduce((acc, a) => acc + a.percentOfTotal, 0)
expect(sum).toBeCloseTo(100, 1)
})
it('tiempo total = 5 × 30 min = 150 min', () => {
const result = runSimulation(buildInput(xml, { executionTimeMinutes: 30 }))
expect(result.totalTimeMinutes).toBeCloseTo(150)
})
})
// ─── medium-with-gateways.bpmn ───────────────────────────────────────────────
describe('simulación / medium-with-gateways.bpmn', () => {
const xml = loadSample('medium-with-gateways.bpmn')
it('corre sin lanzar error con costos dummy', () => {
expect(() => runSimulation(buildInput(xml))).not.toThrow()
})
it('no genera warnings (sin loops)', () => {
const result = runSimulation(buildInput(xml))
expect(result.warnings).toHaveLength(0)
})
it('perActivity tiene exactamente 11 entradas', () => {
const result = runSimulation(buildInput(xml))
expect(result.perActivity).toHaveLength(11)
})
it('actividades antes de cualquier gateway tienen prob = 1.0', () => {
const result = runSimulation(buildInput(xml))
const recv = result.perActivity.find((a) => a.bpmnElementId === 'task_recv')
const valid = result.perActivity.find((a) => a.bpmnElementId === 'task_valid')
expect(recv?.executionProbability).toBeCloseTo(1.0)
expect(valid?.executionProbability).toBeCloseTo(1.0)
})
it('gateway XOR con probs 0.7/0.3 propaga correctamente', () => {
// gw_datos: completo(idx 0)=0.7, incompleto(idx 1)=0.3
const result = runSimulation(
buildInput(xml, {
gatewayProbsOverride: { gw_datos: [0.7, 0.3] },
})
)
const score = result.perActivity.find((a) => a.bpmnElementId === 'task_score')
const pedirDocs = result.perActivity.find((a) => a.bpmnElementId === 'task_pedir_docs')
expect(score?.executionProbability).toBeCloseTo(0.7, 1)
expect(pedirDocs?.executionProbability).toBeCloseTo(0.3, 1)
})
it('gateway paralelo AND: ambas ramas tienen prob igual a la entrada', () => {
// gw_datos completo=1.0 para simplificar, gw_parallel_split AND ambas =1
const result = runSimulation(
buildInput(xml, {
gatewayProbsOverride: {
gw_datos: [1.0, 0.0], // siempre el camino completo
gw_decision: [0.5, 0.5],
},
})
)
const bureau = result.perActivity.find((a) => a.bpmnElementId === 'task_bureau')
const empleador = result.perActivity.find((a) => a.bpmnElementId === 'task_empleador')
// Ambas ramas del AND deben tener prob similar (≈ prob del score)
expect(bureau?.executionProbability).toBeGreaterThan(0)
expect(empleador?.executionProbability).toBeGreaterThan(0)
expect(Math.abs((bureau?.executionProbability ?? 0) - (empleador?.executionProbability ?? 0))).toBeLessThan(0.01)
})
it('perActivity ordenado descendente por costo', () => {
const result = runSimulation(buildInput(xml))
for (let i = 0; i < result.perActivity.length - 1; i++) {
expect(result.perActivity[i].expectedTotalCost).toBeGreaterThanOrEqual(
result.perActivity[i + 1].expectedTotalCost
)
}
})
})
// ─── complex-with-loop.bpmn ───────────────────────────────────────────────────
describe('simulación / complex-with-loop.bpmn', () => {
const xml = loadSample('complex-with-loop.bpmn')
it('corre sin lanzar error (a pesar del loop)', () => {
expect(() => runSimulation(buildInput(xml))).not.toThrow()
})
it('🚨 genera al menos un warning de loop detectado', () => {
const result = runSimulation(buildInput(xml))
expect(result.warnings.length).toBeGreaterThan(0)
const hasLoopWarning = result.warnings.some(
(w) => w.toLowerCase().includes('loop') || w.toLowerCase().includes('ciclo')
)
expect(hasLoopWarning).toBe(true)
})
it('el warning menciona el nodo "Inspección visual y funcional"', () => {
const result = runSimulation(buildInput(xml))
const loopWarning = result.warnings.find(
(w) => w.toLowerCase().includes('loop') || w.toLowerCase().includes('ciclo')
)
expect(loopWarning).toContain('Inspección visual y funcional')
})
it('resultado es válido a pesar del loop: costo > 0 y 9 actividades', () => {
const result = runSimulation(buildInput(xml, { directCostFixed: 100 }))
expect(result.totalCost).toBeGreaterThan(0)
expect(result.perActivity).toHaveLength(9)
})
it('con probabilidades reales (60% aprobado, 30% correcciones, 10% rechazo)', () => {
const result = runSimulation(
buildInput(xml, {
directCostFixed: 200,
overheadPercentage: 0.15,
gatewayProbsOverride: {
// gw_resultado: 3 salidas — aprobado 0.6, obs_menores 0.3, rechazo 0.1
gw_resultado: [0.6, 0.3, 0.1],
},
})
)
expect(result.totalCost).toBeGreaterThan(0)
expect(result.warnings.length).toBeGreaterThan(0)
// task_inspeccion tiene prob alta (es el nodo más costoso del proceso con loop)
const inspeccion = result.perActivity.find((a) => a.bpmnElementId === 'task_inspeccion')
expect(inspeccion).toBeDefined()
expect(inspeccion!.executionProbability).toBeGreaterThan(0)
// task_correcciones tiene prob = prob del camino de correcciones ≈ 0.3
const correcciones = result.perActivity.find((a) => a.bpmnElementId === 'task_correcciones')
expect(correcciones).toBeDefined()
expect(correcciones!.executionProbability).toBeCloseTo(0.3, 1)
// task_rechazo tiene prob ≈ 0.1
const rechazo = result.perActivity.find((a) => a.bpmnElementId === 'task_rechazo')
expect(rechazo).toBeDefined()
expect(rechazo!.executionProbability).toBeCloseTo(0.1, 1)
})
it('percentOfTotal suma ≈ 100%', () => {
const result = runSimulation(buildInput(xml))
if (result.perActivity.length > 0) {
const sum = result.perActivity.reduce((acc, a) => acc + a.percentOfTotal, 0)
expect(sum).toBeCloseTo(100, 0)
}
})
})