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 = ` f1 f1f2 f2f3 f3 ` // BPMN con gateway XOR const XOR_BPMN = ` f1 f1f2 f2f3f4 f3f5 f4f6 f5f6 ` 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('')).toThrow() }) }) describe('runSimulation', () => { const makeResource = (id: string, name: string, costPerHour: number): Resource => ({ id, name, type: 'role', costPerHour, }) const makeActivity = ( bpmnElementId: string, name: string, directCost: number, minutes: number, resources: Array<{ resourceId: string; utilizationMinutes: number; units?: number }> = [] ): Activity => ({ id: `act-${bpmnElementId}`, processId: 'proc1', bpmnElementId, name, type: 'task', directCostFixed: directCost, executionTimeMinutes: minutes, assignedResources: resources.map((r) => ({ ...r, units: r.units ?? 1 })), automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [], }) 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', utilizationMinutes: 60, units: 1 }, // 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 automatedAssignedResources?: import('@/domain/types').ActivityResourceAssignment[] } = {} ): 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, automatedAssignedResources: opts.automatedAssignedResources ?? [], }) // ─── 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 = ` f1 f1f2 f2f3f4 f3f5 f4f6 f5f6 ` 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) } } }) }) // ─── Sprint 2: modelo utilizationMinutes + units ────────────────────────────── describe('runSimulation — recursos con modelo utilizationMinutes + units', () => { const makeRes = (id: string, costPerHour: number): Resource => ({ id, name: `Recurso ${id}`, type: 'role', costPerHour, }) const makeAct = ( bpmnElementId: string, directCost: number, minutes: number, resources: import('@/domain/types').ActivityResourceAssignment[] = [] ): Activity => ({ id: `act-${bpmnElementId}`, processId: 'proc1', bpmnElementId, name: bpmnElementId, type: 'task', directCostFixed: directCost, executionTimeMinutes: minutes, assignedResources: resources, automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, automatedAssignedResources: [], }) it('actividad sin recursos: costo = directCostFixed', () => { const act = makeAct('t1', 200, 30) const result = runSimulation({ processXml: LINEAR_BPMN, activities: new Map([['taskA', { ...act, bpmnElementId: 'taskA' }]]), gateways: new Map(), resources: new Map(), globalSettings: { currency: 'USD', overheadPercentage: 0 }, }) expect(result.totalDirectCost).toBe(200) }) it('actividad con 1 recurso: costo = fijo + costPerHour × utilizationMinutes/60 × units × execProb', () => { // Analista: $120/h, 30 min, 1 unidad → $60. Fijo: $50. Total: $110 const act = makeAct('taskA', 50, 60, [{ resourceId: 'r1', utilizationMinutes: 30, units: 1 }]) const result = runSimulation({ processXml: LINEAR_BPMN, activities: new Map([['taskA', act]]), gateways: new Map(), resources: new Map([['r1', makeRes('r1', 120)]]), globalSettings: { currency: 'USD', overheadPercentage: 0 }, }) const actResult = result.perActivity.find((a) => a.bpmnElementId === 'taskA') expect(actResult?.expectedDirectCost).toBeCloseTo(110) }) it('actividad con 2 recursos: costo = fijo + suma de costos de recursos', () => { // r1: $60/h × 30min/60 × 1 = $30. r2: $40/h × 60min/60 × 1 = $40. Fijo: $20. Total: $90 const act = makeAct('taskA', 20, 60, [ { resourceId: 'r1', utilizationMinutes: 30, units: 1 }, { resourceId: 'r2', utilizationMinutes: 60, units: 1 }, ]) const result = runSimulation({ processXml: LINEAR_BPMN, activities: new Map([['taskA', act]]), gateways: new Map(), resources: new Map([['r1', makeRes('r1', 60)], ['r2', makeRes('r2', 40)]]), globalSettings: { currency: 'USD', overheadPercentage: 0 }, }) const actResult = result.perActivity.find((a) => a.bpmnElementId === 'taskA') expect(actResult?.expectedDirectCost).toBeCloseTo(90) }) it('units = 2: costo de recurso se duplica', () => { // $60/h × 30min/60 × 2 unidades = $60. Fijo: $0. Total: $60 const act = makeAct('taskA', 0, 30, [{ resourceId: 'r1', utilizationMinutes: 30, units: 2 }]) const result = runSimulation({ processXml: LINEAR_BPMN, activities: new Map([['taskA', act]]), gateways: new Map(), resources: new Map([['r1', makeRes('r1', 60)]]), globalSettings: { currency: 'USD', overheadPercentage: 0 }, }) expect(result.totalDirectCost).toBeCloseTo(60) }) it('utilizationMinutes = 0: el recurso no agrega costo', () => { const act = makeAct('taskA', 100, 60, [{ resourceId: 'r1', utilizationMinutes: 0, units: 1 }]) const result = runSimulation({ processXml: LINEAR_BPMN, activities: new Map([['taskA', act]]), gateways: new Map(), resources: new Map([['r1', makeRes('r1', 200)]]), globalSettings: { currency: 'USD', overheadPercentage: 0 }, }) expect(result.totalDirectCost).toBe(100) }) it('resource no encontrado en el mapa: se ignora sin lanzar error', () => { const act = makeAct('taskA', 100, 60, [{ resourceId: 'no-existe', utilizationMinutes: 30, units: 1 }]) expect(() => runSimulation({ processXml: LINEAR_BPMN, activities: new Map([['taskA', act]]), gateways: new Map(), resources: new Map(), globalSettings: { currency: 'USD', overheadPercentage: 0 }, })).not.toThrow() const result = runSimulation({ processXml: LINEAR_BPMN, activities: new Map([['taskA', act]]), gateways: new Map(), resources: new Map(), globalSettings: { currency: 'USD', overheadPercentage: 0 }, }) expect(result.totalDirectCost).toBe(100) }) })