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

211 lines
7.6 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { parseBpmnXml, extractActivityElements, extractGatewayElements } from '@/domain/bpmn-parser'
import { runSimulation } from '@/domain/simulation'
import type { Activity, Resource, GatewayConfig, SimulationInput } from '@/domain/types'
// BPMN mínimo para tests — proceso lineal de 2 tareas
const LINEAR_BPMN = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" targetNamespace="test">
<process id="proc1" isExecutable="false">
<startEvent id="start"><outgoing>f1</outgoing></startEvent>
<task id="taskA" name="Tarea A"><incoming>f1</incoming><outgoing>f2</outgoing></task>
<task id="taskB" name="Tarea B"><incoming>f2</incoming><outgoing>f3</outgoing></task>
<endEvent id="end"><incoming>f3</incoming></endEvent>
<sequenceFlow id="f1" sourceRef="start" targetRef="taskA"/>
<sequenceFlow id="f2" sourceRef="taskA" targetRef="taskB"/>
<sequenceFlow id="f3" sourceRef="taskB" targetRef="end"/>
</process>
</definitions>`
// BPMN con gateway XOR
const XOR_BPMN = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" targetNamespace="test">
<process id="proc2" isExecutable="false">
<startEvent id="start"><outgoing>f1</outgoing></startEvent>
<task id="taskA" name="Evaluar"><incoming>f1</incoming><outgoing>f2</outgoing></task>
<exclusiveGateway id="gw1" name="Decision"><incoming>f2</incoming><outgoing>f3</outgoing><outgoing>f4</outgoing></exclusiveGateway>
<task id="taskB" name="Camino Si"><incoming>f3</incoming><outgoing>f5</outgoing></task>
<task id="taskC" name="Camino No"><incoming>f4</incoming><outgoing>f6</outgoing></task>
<endEvent id="end"><incoming>f5</incoming><incoming>f6</incoming></endEvent>
<sequenceFlow id="f1" sourceRef="start" targetRef="taskA"/>
<sequenceFlow id="f2" sourceRef="taskA" targetRef="gw1"/>
<sequenceFlow id="f3" sourceRef="gw1" targetRef="taskB"/>
<sequenceFlow id="f4" sourceRef="gw1" targetRef="taskC"/>
<sequenceFlow id="f5" sourceRef="taskB" targetRef="end"/>
<sequenceFlow id="f6" sourceRef="taskC" targetRef="end"/>
</process>
</definitions>`
describe('parseBpmnXml', () => {
it('parsea proceso lineal y extrae nodos y flujos', () => {
const graph = parseBpmnXml(LINEAR_BPMN)
expect(graph.nodes.size).toBe(4) // start, taskA, taskB, end
expect(graph.flows.size).toBe(3)
expect(graph.startEventIds).toEqual(['start'])
})
it('extrae actividades correctamente', () => {
const graph = parseBpmnXml(LINEAR_BPMN)
const activities = extractActivityElements(graph)
expect(activities).toHaveLength(2)
expect(activities[0].bpmnElementId).toBe('taskA')
expect(activities[1].bpmnElementId).toBe('taskB')
})
it('extrae gateways divergentes', () => {
const graph = parseBpmnXml(XOR_BPMN)
const gateways = extractGatewayElements(graph)
expect(gateways).toHaveLength(1)
expect(gateways[0].bpmnElementId).toBe('gw1')
expect(gateways[0].outgoing).toHaveLength(2)
})
it('lanza error si el XML no tiene proceso', () => {
expect(() => parseBpmnXml('<bad/>')).toThrow()
})
})
describe('runSimulation', () => {
const makeResource = (id: string, name: string, costPerHour: number): Resource => ({
id,
processId: 'proc1',
name,
type: 'role',
costPerHour,
})
const makeActivity = (
bpmnElementId: string,
name: string,
directCost: number,
minutes: number,
resources: Array<{ resourceId: string; utilizationPercent: number }> = []
): Activity => ({
id: `act-${bpmnElementId}`,
processId: 'proc1',
bpmnElementId,
name,
type: 'task',
directCostFixed: directCost,
executionTimeMinutes: minutes,
assignedResources: resources,
})
it('calcula costo total sin recursos — proceso lineal', () => {
const activityA = makeActivity('taskA', 'Tarea A', 100, 30)
const activityB = makeActivity('taskB', 'Tarea B', 200, 60)
const input: SimulationInput = {
processXml: LINEAR_BPMN,
activities: new Map([
['taskA', activityA],
['taskB', activityB],
]),
gateways: new Map(),
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
}
const result = runSimulation(input)
expect(result.totalDirectCost).toBe(300)
expect(result.totalIndirectCost).toBe(0)
expect(result.totalCost).toBe(300)
expect(result.warnings).toHaveLength(0)
})
it('aplica overhead correctamente', () => {
const activityA = makeActivity('taskA', 'Tarea A', 100, 30)
const input: SimulationInput = {
processXml: LINEAR_BPMN,
activities: new Map([['taskA', activityA]]),
gateways: new Map(),
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0.2 },
}
const result = runSimulation(input)
expect(result.totalDirectCost).toBe(100)
expect(result.totalIndirectCost).toBeCloseTo(20)
expect(result.totalCost).toBeCloseTo(120)
})
it('incluye costo de recursos asignados', () => {
const resource = makeResource('res1', 'Analista', 60) // $60/hora
const activityA = makeActivity('taskA', 'Tarea A', 0, 60, [
{ resourceId: 'res1', utilizationPercent: 1.0 }, // 1 hora completa
])
const input: SimulationInput = {
processXml: LINEAR_BPMN,
activities: new Map([['taskA', activityA]]),
gateways: new Map(),
resources: new Map([['res1', resource]]),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
}
const result = runSimulation(input)
expect(result.totalDirectCost).toBeCloseTo(60)
})
it('propaga probabilidad 0.7/0.3 en gateway XOR', () => {
const actA = makeActivity('taskA', 'Evaluar', 100, 30)
const actB = makeActivity('taskB', 'Camino Si', 200, 60)
const actC = makeActivity('taskC', 'Camino No', 100, 30)
const gw: GatewayConfig = {
id: 'gw-1',
processId: 'proc2',
bpmnElementId: 'gw1',
gatewayType: 'exclusive',
branches: [
{ flowId: 'f3', targetElementId: 'taskB', probability: 0.7 },
{ flowId: 'f4', targetElementId: 'taskC', probability: 0.3 },
],
}
const input: SimulationInput = {
processXml: XOR_BPMN,
activities: new Map([
['taskA', actA],
['taskB', actB],
['taskC', actC],
]),
gateways: new Map([['gw1', gw]]),
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
}
const result = runSimulation(input)
const bResult = result.perActivity.find((a) => a.bpmnElementId === 'taskB')
const cResult = result.perActivity.find((a) => a.bpmnElementId === 'taskC')
expect(bResult?.executionProbability).toBeCloseTo(0.7, 1)
expect(cResult?.executionProbability).toBeCloseTo(0.3, 1)
// Costo esperado de B = 200 * 0.7 = 140
expect(bResult?.expectedDirectCost).toBeCloseTo(140)
// Costo esperado de C = 100 * 0.3 = 30
expect(cResult?.expectedDirectCost).toBeCloseTo(30)
})
it('ordena perActivity por costo total descendente', () => {
const actA = makeActivity('taskA', 'Tarea A', 50, 10)
const actB = makeActivity('taskB', 'Tarea B', 500, 60)
const input: SimulationInput = {
processXml: LINEAR_BPMN,
activities: new Map([
['taskA', actA],
['taskB', actB],
]),
gateways: new Map(),
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
}
const result = runSimulation(input)
expect(result.perActivity[0].bpmnElementId).toBe('taskB')
expect(result.perActivity[1].bpmnElementId).toBe('taskA')
})
})