Files
inq-roi-simulador-web/tests/integration/simulation-engine.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

283 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: [],
automatable: false,
automatedCostFixed: 0,
automatedTimeMinutes: 0,
automatedAssignedResources: [],
},
])
)
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)
}
})
})