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>
This commit is contained in:
278
tests/integration/simulation-engine.test.ts
Normal file
278
tests/integration/simulation-engine.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user