393 lines
16 KiB
TypeScript
393 lines
16 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,
|
|
automatable: false,
|
|
automatedCostFixed: 0,
|
|
automatedTimeMinutes: 0,
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|
|
|
|
// ─── Sprint 1: escenario 'automated' + actividades automatizables ─────────────
|
|
|
|
describe('runSimulation — scenario automated', () => {
|
|
const makeAutoActivity = (
|
|
bpmnElementId: string,
|
|
opts: {
|
|
directCost?: number; directTime?: number
|
|
automatable?: boolean; automatedCost?: number; automatedTime?: number
|
|
} = {}
|
|
): Activity => ({
|
|
id: `act-${bpmnElementId}`,
|
|
processId: 'proc1',
|
|
bpmnElementId,
|
|
name: bpmnElementId,
|
|
type: 'task',
|
|
directCostFixed: opts.directCost ?? 100,
|
|
executionTimeMinutes: opts.directTime ?? 60,
|
|
assignedResources: [],
|
|
automatable: opts.automatable ?? false,
|
|
automatedCostFixed: opts.automatedCost ?? 0,
|
|
automatedTimeMinutes: opts.automatedTime ?? 0,
|
|
})
|
|
|
|
// ─── scenario=automated con costos en cero ────────────────────────────────
|
|
|
|
it('scenario=automated con automatedCostFixed=0 y automatedTimeMinutes=0 devuelve cost=0 time=0 sin NaN', () => {
|
|
const act = makeAutoActivity('taskA', {
|
|
directCost: 500, directTime: 60,
|
|
automatable: true, automatedCost: 0, automatedTime: 0,
|
|
})
|
|
|
|
const result = runSimulation({
|
|
processXml: LINEAR_BPMN,
|
|
activities: new Map([['taskA', act], ['taskB', makeAutoActivity('taskB')]]),
|
|
gateways: new Map(),
|
|
resources: new Map(),
|
|
globalSettings: { currency: 'USD', overheadPercentage: 0 },
|
|
scenario: 'automated',
|
|
})
|
|
|
|
const actResult = result.perActivity.find((a) => a.bpmnElementId === 'taskA')!
|
|
expect(actResult).toBeDefined()
|
|
expect(actResult.expectedDirectCost).toBe(0)
|
|
expect(actResult.executionTimeMinutes).toBe(0)
|
|
|
|
// Verificación explícita de ausencia de NaN en todos los campos numéricos
|
|
expect(Number.isNaN(actResult.expectedDirectCost)).toBe(false)
|
|
expect(Number.isNaN(actResult.expectedIndirectCost)).toBe(false)
|
|
expect(Number.isNaN(actResult.expectedTotalCost)).toBe(false)
|
|
expect(Number.isNaN(actResult.percentOfTotal)).toBe(false)
|
|
expect(Number.isNaN(result.totalCost)).toBe(false)
|
|
})
|
|
|
|
it('actividad NO automatable mantiene costos originales en scenario=automated', () => {
|
|
const act = makeAutoActivity('taskA', {
|
|
directCost: 200, directTime: 30,
|
|
automatable: false, automatedCost: 0, automatedTime: 0,
|
|
})
|
|
|
|
const result = runSimulation({
|
|
processXml: LINEAR_BPMN,
|
|
activities: new Map([['taskA', act], ['taskB', makeAutoActivity('taskB')]]),
|
|
gateways: new Map(),
|
|
resources: new Map(),
|
|
globalSettings: { currency: 'USD', overheadPercentage: 0 },
|
|
scenario: 'automated',
|
|
})
|
|
|
|
const actResult = result.perActivity.find((a) => a.bpmnElementId === 'taskA')!
|
|
// automatable=false → usa costo original
|
|
expect(actResult.expectedDirectCost).toBeCloseTo(200)
|
|
expect(actResult.executionTimeMinutes).toBe(30)
|
|
})
|
|
|
|
// ─── La probabilidad de ejecución NO depende del scenario ────────────────
|
|
|
|
it('probabilidad de ejecución NO cambia entre scenario=actual y scenario=automated', () => {
|
|
// Usamos el BPMN XOR: taskB tiene prob 0.7, taskC tiene prob 0.3
|
|
const XOR_BPMN_LOCAL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" targetNamespace="test">
|
|
<process id="p" isExecutable="false">
|
|
<startEvent id="start"><outgoing>f1</outgoing></startEvent>
|
|
<task id="taskA" name="A"><incoming>f1</incoming><outgoing>f2</outgoing></task>
|
|
<exclusiveGateway id="gw1"><incoming>f2</incoming><outgoing>f3</outgoing><outgoing>f4</outgoing></exclusiveGateway>
|
|
<task id="taskB" name="B"><incoming>f3</incoming><outgoing>f5</outgoing></task>
|
|
<task id="taskC" name="C"><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>`
|
|
|
|
const activities = new Map([
|
|
['taskA', makeAutoActivity('taskA', { automatable: true, automatedCost: 5, automatedTime: 2 })],
|
|
['taskB', makeAutoActivity('taskB', { automatable: true, automatedCost: 0, automatedTime: 0 })],
|
|
['taskC', makeAutoActivity('taskC', { automatable: false })],
|
|
])
|
|
|
|
const gateways = new Map([['gw1', {
|
|
id: 'gw1', processId: 'proc1', bpmnElementId: 'gw1', gatewayType: 'exclusive' as const,
|
|
branches: [
|
|
{ flowId: 'f3', targetElementId: 'taskB', probability: 0.7 },
|
|
{ flowId: 'f4', targetElementId: 'taskC', probability: 0.3 },
|
|
],
|
|
}]])
|
|
|
|
const baseInput = {
|
|
processXml: XOR_BPMN_LOCAL,
|
|
activities,
|
|
gateways,
|
|
resources: new Map(),
|
|
globalSettings: { currency: 'USD', overheadPercentage: 0 },
|
|
}
|
|
|
|
const resultActual = runSimulation({ ...baseInput, scenario: 'actual' })
|
|
const resultAutomated = runSimulation({ ...baseInput, scenario: 'automated' })
|
|
|
|
// taskA tiene prob 1.0 en ambos
|
|
const aActual = resultActual.perActivity.find((a) => a.bpmnElementId === 'taskA')!
|
|
const aAuto = resultAutomated.perActivity.find((a) => a.bpmnElementId === 'taskA')!
|
|
expect(aActual.executionProbability).toBeCloseTo(1.0)
|
|
expect(aAuto.executionProbability).toBeCloseTo(1.0)
|
|
|
|
// taskB tiene prob 0.7 en ambos (el scenario no cambia el grafo)
|
|
const bActual = resultActual.perActivity.find((a) => a.bpmnElementId === 'taskB')!
|
|
const bAuto = resultAutomated.perActivity.find((a) => a.bpmnElementId === 'taskB')!
|
|
expect(bActual.executionProbability).toBeCloseTo(0.7)
|
|
expect(bAuto.executionProbability).toBeCloseTo(0.7)
|
|
|
|
// taskC tiene prob 0.3 en ambos
|
|
const cActual = resultActual.perActivity.find((a) => a.bpmnElementId === 'taskC')!
|
|
const cAuto = resultAutomated.perActivity.find((a) => a.bpmnElementId === 'taskC')!
|
|
expect(cActual.executionProbability).toBeCloseTo(0.3)
|
|
expect(cAuto.executionProbability).toBeCloseTo(0.3)
|
|
|
|
// Los costos SÍ cambian para actividades automatable=true
|
|
// taskB en actual: directCostFixed=100; en automated: automatedCostFixed=0
|
|
expect(bActual.expectedDirectCost).toBeGreaterThan(0)
|
|
expect(bAuto.expectedDirectCost).toBe(0)
|
|
|
|
// taskC no es automatable → costos iguales en ambos
|
|
expect(cActual.expectedDirectCost).toBeCloseTo(cAuto.expectedDirectCost)
|
|
})
|
|
|
|
it('NaN no aparece en ningún campo cuando TODOS los costos son cero', () => {
|
|
const activities = new Map([
|
|
['taskA', makeAutoActivity('taskA', { directCost: 0, directTime: 0, automatable: true, automatedCost: 0, automatedTime: 0 })],
|
|
['taskB', makeAutoActivity('taskB', { directCost: 0, directTime: 0, automatable: true, automatedCost: 0, automatedTime: 0 })],
|
|
])
|
|
|
|
for (const scenario of ['actual', 'automated'] as const) {
|
|
const result = runSimulation({
|
|
processXml: LINEAR_BPMN,
|
|
activities,
|
|
gateways: new Map(),
|
|
resources: new Map(),
|
|
globalSettings: { currency: 'USD', overheadPercentage: 0.2 },
|
|
scenario,
|
|
})
|
|
|
|
expect(Number.isNaN(result.totalCost)).toBe(false)
|
|
expect(Number.isNaN(result.totalDirectCost)).toBe(false)
|
|
expect(Number.isNaN(result.totalIndirectCost)).toBe(false)
|
|
|
|
for (const act of result.perActivity) {
|
|
expect(Number.isNaN(act.expectedDirectCost)).toBe(false)
|
|
expect(Number.isNaN(act.expectedIndirectCost)).toBe(false)
|
|
expect(Number.isNaN(act.expectedTotalCost)).toBe(false)
|
|
expect(Number.isNaN(act.percentOfTotal)).toBe(false)
|
|
expect(Number.isNaN(act.executionProbability)).toBe(false)
|
|
}
|
|
}
|
|
})
|
|
})
|