/** * Tests de derivación de datos del reporte. * Para cada uno de los 3 sample BPMNs, simula con costos predefinidos * y verifica invariantes matemáticos y de presentación. */ 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 { buildCostCompositionOption, buildTopActivitiesOption, aggregateResourceCosts } from '@/lib/chart-options' import { formatCurrency } from '@/lib/format' import { bpmnTypeToGatewayType } from '@/domain/types' import type { Activity, GatewayConfig, Resource, SimulationInput } from '@/domain/types' function loadBpmn(name: string): string { return readFileSync(resolve(__dirname, '../../../public/sample-processes', name), 'utf-8') } function buildSimInput(xml: string, directCost: number, overhead: number, currency = 'USD'): SimulationInput { const graph = parseBpmnXml(xml) const actEls = extractActivityElements(graph) const gwEls = extractGatewayElements(graph) const processId = 'report-test' const activities = new Map( actEls.map((el) => [el.bpmnElementId, { id: uuidv4(), processId, bpmnElementId: el.bpmnElementId, name: el.name, type: el.type, directCostFixed: directCost, executionTimeMinutes: 30, assignedResources: [], automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0, }]) ) const gateways = new Map( gwEls.map((el) => { const gwType = bpmnTypeToGatewayType(el.gatewayType as any) const n = el.outgoing.length return [el.bpmnElementId, { id: uuidv4(), processId, bpmnElementId: el.bpmnElementId, gatewayType: gwType, branches: el.outgoing.map((flowId) => ({ flowId, targetElementId: graph.flows.get(flowId)?.targetRef ?? '', probability: gwType === 'parallel' ? 1.0 : parseFloat((1 / n).toFixed(4)), })), }] }) ) return { processXml: xml, activities, gateways, resources: new Map(), globalSettings: { currency, overheadPercentage: overhead }, } } // ─── INVARIANTES MATEMÁTICOS ───────────────────────────────────────────────── describe('invariantes matemáticos — los 3 BPMNs', () => { const BPMNS = [ { name: 'simple-linear.bpmn', overhead: 0.2, label: 'simple-linear' }, { name: 'medium-with-gateways.bpmn', overhead: 0.15, label: 'medium-gateways' }, { name: 'complex-with-loop.bpmn', overhead: 0.25, label: 'complex-loop' }, ] for (const { name, overhead, label } of BPMNS) { describe(label, () => { const xml = loadBpmn(name) const result = runSimulation(buildSimInput(xml, 100, overhead)) it('directo + indirecto = total (dentro de tolerancia float)', () => { expect(result.totalDirectCost + result.totalIndirectCost).toBeCloseTo(result.totalCost, 6) }) it('overhead indirecto = directCost × overheadPct', () => { expect(result.totalIndirectCost).toBeCloseTo(result.totalDirectCost * overhead, 6) }) it('suma de percentOfTotal de todas las actividades ≈ 100%', () => { const sum = result.perActivity.reduce((s, a) => s + a.percentOfTotal, 0) expect(sum).toBeCloseTo(100, 1) }) it('suma de expectedTotalCost de actividades ≈ totalCost', () => { const sum = result.perActivity.reduce((s, a) => s + a.expectedTotalCost, 0) expect(sum).toBeCloseTo(result.totalCost, 4) }) it('perActivity ordenado desc por expectedTotalCost', () => { for (let i = 0; i < result.perActivity.length - 1; i++) { expect(result.perActivity[i].expectedTotalCost).toBeGreaterThanOrEqual( result.perActivity[i + 1].expectedTotalCost ) } }) it('cantidad de filas de tabla = cantidad de Activity parseadas', () => { const graph = parseBpmnXml(xml) const actEls = extractActivityElements(graph) expect(result.perActivity).toHaveLength(actEls.length) }) it('cada actividad: directCost + indirectCost = totalCost (dentro de tolerancia)', () => { for (const act of result.perActivity) { expect(act.expectedDirectCost + act.expectedIndirectCost).toBeCloseTo(act.expectedTotalCost, 4) } }) it('sin recursos: suma de resourceCostBreakdown = 0 para cada actividad', () => { for (const act of result.perActivity) { const resourceSum = act.resourceCostBreakdown.reduce((s, r) => s + r.cost, 0) expect(resourceSum).toBeCloseTo(0, 6) } }) it('totalTimeMinutes > 0 cuando hay actividades con tiempo > 0', () => { expect(result.totalTimeMinutes).toBeGreaterThan(0) }) }) } }) // ─── FORMATO DE MONEDA EN EL REPORTE ───────────────────────────────────────── describe('formato de moneda en datos del reporte', () => { it('USD: KPI costo total formateado correctamente ($1,200.00)', () => { const xml = loadBpmn('simple-linear.bpmn') const result = runSimulation(buildSimInput(xml, 100, 0.2, 'USD')) const formatted = formatCurrency(result.totalCost, 'USD') // Simple-linear: 5 actividades × $100 × 1.2 = $600 expect(formatted).toMatch(/600/) expect(formatted).toMatch(/\$|USD/) }) it('PYG: KPI costo total formateado correctamente (sin decimales en PYG)', () => { const xml = loadBpmn('simple-linear.bpmn') const result = runSimulation(buildSimInput(xml, 100000, 0.2, 'PYG')) const formatted = formatCurrency(result.totalCost, 'PYG') expect(formatted).toBeTruthy() expect(formatted).toMatch(/600/) }) it('USD vs PYG: misma cantidad produce representaciones distintas', () => { const amountUsd = formatCurrency(1000, 'USD') const amountPyg = formatCurrency(1000, 'PYG') expect(amountUsd).not.toBe(amountPyg) }) }) // ─── DONUT CHART OPTION — snapshot del objeto ECharts ──────────────────────── describe('buildCostCompositionOption — estructura del objeto ECharts', () => { it('tiene exactamente 1 serie de tipo pie', () => { const xml = loadBpmn('simple-linear.bpmn') const result = runSimulation(buildSimInput(xml, 100, 0.2)) const option = buildCostCompositionOption(result, 'USD') const series = option.series as any[] expect(series).toHaveLength(1) expect(series[0].type).toBe('pie') }) it('con overhead > 0: data tiene 2 items (directo + indirecto)', () => { const xml = loadBpmn('simple-linear.bpmn') const result = runSimulation(buildSimInput(xml, 100, 0.2)) const option = buildCostCompositionOption(result, 'USD') const series = option.series as any[] expect(series[0].data).toHaveLength(2) expect(series[0].data[0].name).toBe('Costo directo') expect(series[0].data[1].name).toBe('Costo indirecto (overhead)') }) it('con overhead = 0: data tiene solo 1 item (solo directo)', () => { const xml = loadBpmn('simple-linear.bpmn') const result = runSimulation(buildSimInput(xml, 100, 0)) const option = buildCostCompositionOption(result, 'USD') const series = option.series as any[] expect(series[0].data).toHaveLength(1) expect(series[0].data[0].name).toBe('Costo directo') }) it('los valores de data suman al totalCost', () => { const xml = loadBpmn('medium-with-gateways.bpmn') const result = runSimulation(buildSimInput(xml, 100, 0.2)) const option = buildCostCompositionOption(result, 'USD') const series = option.series as any[] const dataSum = (series[0].data as { value: number }[]).reduce((s, d) => s + d.value, 0) expect(dataSum).toBeCloseTo(result.totalCost, 4) }) it('el graphic label contiene el totalCost formateado', () => { const result = { totalDirectCost: 1000, totalIndirectCost: 200, totalCost: 1200 } const option = buildCostCompositionOption(result, 'USD') const graphic = option.graphic as any[] expect(graphic[0].style.text).toContain('1') expect(graphic[0].style.text).toContain('200') }) it('snapshot: estructura completa del option del donut', () => { const result = { totalDirectCost: 1000, totalIndirectCost: 200, totalCost: 1200 } const option = buildCostCompositionOption(result, 'USD') expect(option).toMatchSnapshot() }) }) // ─── TOP ACTIVITIES CHART — estructura ─────────────────────────────────────── describe('buildTopActivitiesOption — estructura', () => { it('serie de tipo bar con hasta 10 items', () => { const xml = loadBpmn('medium-with-gateways.bpmn') const result = runSimulation(buildSimInput(xml, 100, 0.2)) const option = buildTopActivitiesOption(result.perActivity, 'relative', 'USD') const series = option.series as any[] expect(series[0].type).toBe('bar') expect(series[0].data.length).toBeLessThanOrEqual(10) expect(series[0].data.length).toBeGreaterThan(0) }) it('la primera barra (actividad más costosa) tiene color del extremo cálido', () => { const xml = loadBpmn('simple-linear.bpmn') const result = runSimulation(buildSimInput(xml, 200, 0)) // En modo relativo con varianza real, la 1ra actividad → t=1 → rojo // En simple-linear todos cuestan igual → NEUTRAL_HEATMAP_COLOR const option = buildTopActivitiesOption(result.perActivity, 'relative', 'USD') const series = option.series as any[] // Verifica que itemStyle.color existe en cada barra series[0].data.forEach((item: any) => { expect(item.itemStyle).toBeDefined() expect(item.itemStyle.color).toMatch(/^#[0-9a-f]{6}$/) }) }) it('yAxis data contiene los nombres de actividades (truncados)', () => { const xml = loadBpmn('simple-linear.bpmn') const result = runSimulation(buildSimInput(xml, 100, 0)) const option = buildTopActivitiesOption(result.perActivity, 'relative', 'USD') const yAxis = option.yAxis as any expect(yAxis.data).toHaveLength(result.perActivity.length) // Los nombres deben existir (no vacíos) yAxis.data.forEach((name: string) => expect(name.length).toBeGreaterThan(0)) }) }) // ─── AGGREGATE RESOURCE COSTS ───────────────────────────────────────────────── describe('aggregateResourceCosts', () => { it('sin recursos: activeTypes vacío, totales en cero', () => { const xml = loadBpmn('simple-linear.bpmn') const result = runSimulation(buildSimInput(xml, 100, 0)) const { byType, activeTypes } = aggregateResourceCosts(result.perActivity, []) expect(activeTypes).toHaveLength(0) expect(Object.keys(byType)).toHaveLength(0) }) it('con recursos de tipo role: suma correctamente por tipo', () => { const resource: Resource = { id: 'r1', processId: 'p1', name: 'Analista', type: 'role', costPerHour: 60, } const perActivity = [{ activityId: 'a1', bpmnElementId: 't1', activityName: 'Task', type: 'task' as const, expectedDirectCost: 60, expectedIndirectCost: 12, expectedTotalCost: 72, percentOfTotal: 100, executionProbability: 1, expectedExecutions: 1, executionTimeMinutes: 60, resourceCostBreakdown: [{ resourceId: 'r1', resourceName: 'Analista', cost: 60 }], }] const { byType, activeTypes } = aggregateResourceCosts(perActivity, [resource]) expect(activeTypes).toContain('role') expect(byType.role).toBeCloseTo(60) }) }) // ─── EMPTY STATE / ERROR STATE ─────────────────────────────────────────────── describe('estados edge del reporte', () => { it('resultado con 0 actividades: perActivity vacío → totalCost = 0', () => { const result = runSimulation({ processXml: ` f1 f1 `, activities: new Map(), gateways: new Map(), resources: new Map(), globalSettings: { currency: 'USD', overheadPercentage: 0.2 }, }) expect(result.perActivity).toHaveLength(0) expect(result.totalCost).toBe(0) expect(result.warnings).toHaveLength(0) }) })