295 lines
13 KiB
TypeScript
295 lines
13 KiB
TypeScript
/**
|
||
* 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<string, Activity>(
|
||
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<string, GatewayConfig>(
|
||
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<string, Resource>(),
|
||
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: `<?xml version="1.0"?><definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL">
|
||
<process id="p"><startEvent id="s"><outgoing>f1</outgoing></startEvent>
|
||
<endEvent id="e"><incoming>f1</incoming></endEvent>
|
||
<sequenceFlow id="f1" sourceRef="s" targetRef="e"/></process></definitions>`,
|
||
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)
|
||
})
|
||
})
|