282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
/**
|
||
* 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,
|
||
},
|
||
])
|
||
)
|
||
|
||
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)
|
||
}
|
||
})
|
||
})
|