Files
inq-roi-simulador-web/tests/features/report/derivations.test.ts

295 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)
})
})