feat: MVP Fase 0 — Process Cost Platform v0.1.0
Plataforma completa de análisis de costos operativos basada en BPMN 2.0. Funcionalidades incluidas: - Import de archivos BPMN 2.0 por drag & drop + 3 procesos de ejemplo - Visualización read-only del diagrama (bpmn-js) - Configuración de actividades (costo, tiempo, recursos asignados) - CRUD de recursos (rol, persona, sistema, equipo, insumo) - Configuración global (moneda, overhead %, nombre, cliente) - Motor de simulación determinístico agregado con propagación de gateways XOR/AND - Reporte visual con mapa de calor en dos modos (relativo/absoluto) - Gráficos: KPIs, top actividades, composición, costo por recurso - Export PDF (jsPDF + html2canvas) y CSV (papaparse) - Persistencia en IndexedDB (Dexie) - 268 tests unitarios/integración + 6 E2E con Playwright - Deploy: https://process-cost-platform.pages.dev Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
85
tests/features/report/__snapshots__/derivations.test.ts.snap
Normal file
85
tests/features/report/__snapshots__/derivations.test.ts.snap
Normal file
@@ -0,0 +1,85 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`buildCostCompositionOption — estructura del objeto ECharts > snapshot: estructura completa del option del donut 1`] = `
|
||||
{
|
||||
"graphic": [
|
||||
{
|
||||
"left": "center",
|
||||
"style": {
|
||||
"fill": "#1e293b",
|
||||
"fontFamily": "JetBrains Mono, monospace",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "bold",
|
||||
"text": "USD 1.200,00",
|
||||
"textAlign": "center",
|
||||
},
|
||||
"top": "38%",
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"legend": {
|
||||
"bottom": "2%",
|
||||
"itemHeight": 12,
|
||||
"itemWidth": 12,
|
||||
"left": "center",
|
||||
"textStyle": {
|
||||
"color": "#475569",
|
||||
"fontSize": 11,
|
||||
},
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"avoidLabelOverlap": true,
|
||||
"center": [
|
||||
"50%",
|
||||
"44%",
|
||||
],
|
||||
"data": [
|
||||
{
|
||||
"itemStyle": {
|
||||
"color": "#3b82f6",
|
||||
},
|
||||
"name": "Costo directo",
|
||||
"value": 1000,
|
||||
},
|
||||
{
|
||||
"itemStyle": {
|
||||
"color": "#94a3b8",
|
||||
},
|
||||
"name": "Costo indirecto (overhead)",
|
||||
"value": 200,
|
||||
},
|
||||
],
|
||||
"emphasis": {
|
||||
"label": {
|
||||
"fontSize": 13,
|
||||
"fontWeight": "bold",
|
||||
},
|
||||
},
|
||||
"itemStyle": {
|
||||
"borderColor": "#fff",
|
||||
"borderRadius": 4,
|
||||
"borderWidth": 2,
|
||||
},
|
||||
"label": {
|
||||
"fontSize": 11,
|
||||
"formatter": [Function],
|
||||
"show": true,
|
||||
},
|
||||
"labelLine": {
|
||||
"length": 10,
|
||||
"length2": 8,
|
||||
},
|
||||
"radius": [
|
||||
"48%",
|
||||
"68%",
|
||||
],
|
||||
"type": "pie",
|
||||
},
|
||||
],
|
||||
"tooltip": {
|
||||
"formatter": [Function],
|
||||
"trigger": "item",
|
||||
},
|
||||
}
|
||||
`;
|
||||
409
tests/features/report/components.test.tsx
Normal file
409
tests/features/report/components.test.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* Tests de renderizado DOM de los componentes del reporte.
|
||||
* bpmn-js y echarts-for-react se mockean (requieren browser/canvas reales).
|
||||
* useReportData se mockea para inyectar datos controlados.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
|
||||
// ─── Mocks de dependencias del browser ───────────────────────────────────────
|
||||
|
||||
vi.mock('bpmn-js/lib/Viewer', () => ({
|
||||
default: class MockViewer {
|
||||
importXML = vi.fn().mockResolvedValue({ warnings: [] })
|
||||
get = vi.fn().mockImplementation((svc: string) => {
|
||||
if (svc === 'canvas') return { zoom: vi.fn(), addMarker: vi.fn(), removeMarker: vi.fn() }
|
||||
if (svc === 'elementRegistry') return { forEach: vi.fn(), getGraphics: vi.fn().mockReturnValue(null) }
|
||||
if (svc === 'eventBus') return { on: vi.fn(), off: vi.fn() }
|
||||
return {}
|
||||
})
|
||||
destroy = vi.fn()
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('echarts-for-react', () => ({
|
||||
default: ({ style }: { option: unknown; style?: React.CSSProperties }) => (
|
||||
<div data-testid="echarts-chart" style={style} />
|
||||
),
|
||||
}))
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { ActivitySimResult, SimulationResult } from '@/domain/types'
|
||||
|
||||
const mockActivities: ActivitySimResult[] = [
|
||||
{
|
||||
activityId: 'a1', bpmnElementId: 'task1', activityName: 'Recibir solicitud',
|
||||
expectedDirectCost: 500, expectedIndirectCost: 100, expectedTotalCost: 600,
|
||||
percentOfTotal: 60, executionProbability: 1.0, expectedExecutions: 1.0,
|
||||
executionTimeMinutes: 30, resourceCostBreakdown: [],
|
||||
},
|
||||
{
|
||||
activityId: 'a2', bpmnElementId: 'task2', activityName: 'Procesar pedido',
|
||||
expectedDirectCost: 250, expectedIndirectCost: 50, expectedTotalCost: 300,
|
||||
percentOfTotal: 30, executionProbability: 1.0, expectedExecutions: 1.0,
|
||||
executionTimeMinutes: 60, resourceCostBreakdown: [],
|
||||
},
|
||||
{
|
||||
activityId: 'a3', bpmnElementId: 'task3', activityName: 'Notificar cliente',
|
||||
expectedDirectCost: 83, expectedIndirectCost: 17, expectedTotalCost: 100,
|
||||
percentOfTotal: 10, executionProbability: 1.0, expectedExecutions: 1.0,
|
||||
executionTimeMinutes: 15, resourceCostBreakdown: [],
|
||||
},
|
||||
]
|
||||
|
||||
const mockResult: SimulationResult = {
|
||||
totalCost: 1000,
|
||||
totalDirectCost: 833,
|
||||
totalIndirectCost: 167,
|
||||
totalTimeMinutes: 105,
|
||||
perActivity: mockActivities,
|
||||
perResource: [],
|
||||
warnings: [],
|
||||
}
|
||||
|
||||
// ─── KpiBar ───────────────────────────────────────────────────────────────────
|
||||
|
||||
import { KpiBar } from '@/features/report/KpiBar'
|
||||
|
||||
describe('KpiBar', () => {
|
||||
it('renderiza exactamente 4 KPI cards', () => {
|
||||
render(<KpiBar result={mockResult} currency="USD" />)
|
||||
const cards = screen.getAllByTestId('kpi-card')
|
||||
expect(cards).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('la primera card muestra el costo total formateado', () => {
|
||||
render(<KpiBar result={mockResult} currency="USD" />)
|
||||
// El valor $1,000.00 debe aparecer en algún lugar del DOM
|
||||
const cards = screen.getAllByTestId('kpi-card')
|
||||
const allText = cards.map((c) => c.textContent).join(' ')
|
||||
expect(allText).toMatch(/1[.,]000/)
|
||||
})
|
||||
|
||||
it('con currency PYG: muestra el valor sin crash', () => {
|
||||
expect(() => render(<KpiBar result={mockResult} currency="PYG" />)).not.toThrow()
|
||||
const cards = screen.getAllByTestId('kpi-card')
|
||||
expect(cards).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('tiempo total aparece formateado en minutos/horas', () => {
|
||||
render(<KpiBar result={mockResult} currency="USD" />)
|
||||
// 105 min = 1h 45min
|
||||
const text = document.body.textContent ?? ''
|
||||
expect(text).toMatch(/1h|105/)
|
||||
})
|
||||
|
||||
it('cantidad de actividades es visible', () => {
|
||||
render(<KpiBar result={mockResult} currency="USD" />)
|
||||
const text = document.body.textContent ?? ''
|
||||
expect(text).toMatch(/3/)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── WarningsBanner ───────────────────────────────────────────────────────────
|
||||
|
||||
import { WarningsBanner } from '@/features/report/WarningsBanner'
|
||||
|
||||
describe('WarningsBanner', () => {
|
||||
it('no renderiza nada cuando no hay warnings', () => {
|
||||
const { container } = render(<WarningsBanner warnings={[]} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renderiza el banner cuando hay warnings', () => {
|
||||
render(<WarningsBanner warnings={['Loop detectado en task1']} />)
|
||||
expect(screen.getByText(/Loop detectado/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('con múltiples warnings: muestra todos', () => {
|
||||
render(<WarningsBanner warnings={['Warning 1', 'Warning 2', 'Warning 3']} />)
|
||||
expect(screen.getByText(/Warning 1/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Warning 2/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Warning 3/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('warning de loop: muestra nota de impacto en resultados', () => {
|
||||
render(<WarningsBanner warnings={['Loop detectado en "Inspección" forma un ciclo']} />)
|
||||
expect(screen.getByText(/Impacto en los resultados/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('warning sin loop: NO muestra nota de impacto', () => {
|
||||
render(<WarningsBanner warnings={['Actividad "X" no encontrada en diagrama']} />)
|
||||
expect(screen.queryByText(/Impacto en los resultados/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ActivitiesTable ─────────────────────────────────────────────────────────
|
||||
|
||||
import { ActivitiesTable } from '@/features/report/ActivitiesTable'
|
||||
|
||||
describe('ActivitiesTable', () => {
|
||||
it('renderiza exactamente N filas donde N = perActivity.length', () => {
|
||||
render(
|
||||
<ActivitiesTable
|
||||
perActivity={mockActivities}
|
||||
mode="relative"
|
||||
currency="USD"
|
||||
highlightedId={null}
|
||||
onRowClick={vi.fn()}
|
||||
/>
|
||||
)
|
||||
// getAllByRole('row') incluye el header row, entonces total = N + 1
|
||||
const rows = screen.getAllByRole('row')
|
||||
expect(rows).toHaveLength(mockActivities.length + 1)
|
||||
})
|
||||
|
||||
it('los nombres de las actividades aparecen en la tabla', () => {
|
||||
render(
|
||||
<ActivitiesTable
|
||||
perActivity={mockActivities}
|
||||
mode="relative"
|
||||
currency="USD"
|
||||
highlightedId={null}
|
||||
onRowClick={vi.fn()}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Recibir solicitud')).toBeInTheDocument()
|
||||
expect(screen.getByText('Procesar pedido')).toBeInTheDocument()
|
||||
expect(screen.getByText('Notificar cliente')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('click en fila dispara onRowClick con el bpmnElementId correcto', () => {
|
||||
const onRowClick = vi.fn()
|
||||
render(
|
||||
<ActivitiesTable
|
||||
perActivity={mockActivities}
|
||||
mode="relative"
|
||||
currency="USD"
|
||||
highlightedId={null}
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
)
|
||||
const row = screen.getByText('Procesar pedido').closest('tr')!
|
||||
fireEvent.click(row)
|
||||
expect(onRowClick).toHaveBeenCalledWith('task2')
|
||||
expect(onRowClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fila resaltada tiene clase/estado visual distinto', () => {
|
||||
render(
|
||||
<ActivitiesTable
|
||||
perActivity={mockActivities}
|
||||
mode="relative"
|
||||
currency="USD"
|
||||
highlightedId="task1"
|
||||
onRowClick={vi.fn()}
|
||||
/>
|
||||
)
|
||||
const highlightedRow = screen.getByText('Recibir solicitud').closest('tr')!
|
||||
// Verificamos que el atributo data-state='selected' está presente en la fila resaltada
|
||||
expect(highlightedRow).toHaveAttribute('data-state', 'selected')
|
||||
})
|
||||
|
||||
it('tabla sin actividades: renderiza solo el header (0 data rows)', () => {
|
||||
render(
|
||||
<ActivitiesTable
|
||||
perActivity={[]}
|
||||
mode="relative"
|
||||
currency="USD"
|
||||
highlightedId={null}
|
||||
onRowClick={vi.fn()}
|
||||
/>
|
||||
)
|
||||
const rows = screen.getAllByRole('row')
|
||||
expect(rows).toHaveLength(1) // solo el header
|
||||
})
|
||||
})
|
||||
|
||||
// ─── MethodologyFooter ────────────────────────────────────────────────────────
|
||||
|
||||
import { MethodologyFooter } from '@/features/report/MethodologyFooter'
|
||||
|
||||
describe('MethodologyFooter', () => {
|
||||
it('renderiza sin crash con un proceso válido', () => {
|
||||
const proc = {
|
||||
id: 'p1', name: 'Proceso Test', clientName: '',
|
||||
bpmnXml: '<def/>', currency: 'USD',
|
||||
overheadPercentage: 0.2, createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
expect(() => render(<MethodologyFooter process={proc} />)).not.toThrow()
|
||||
})
|
||||
|
||||
it('muestra el overhead configurado', () => {
|
||||
const proc = {
|
||||
id: 'p1', name: 'Proc', clientName: '',
|
||||
bpmnXml: '', currency: 'USD',
|
||||
overheadPercentage: 0.25, createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
render(<MethodologyFooter process={proc} />)
|
||||
const text = document.body.textContent ?? ''
|
||||
expect(text).toMatch(/25%/)
|
||||
})
|
||||
|
||||
it('contiene las palabras clave de la metodología', () => {
|
||||
const proc = {
|
||||
id: 'p1', name: 'Proc', clientName: '',
|
||||
bpmnXml: '', currency: 'USD',
|
||||
overheadPercentage: 0.2, createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
render(<MethodologyFooter process={proc} />)
|
||||
const text = document.body.textContent ?? ''
|
||||
expect(text).toMatch(/Nota metodológica|metodol/i)
|
||||
expect(text).toMatch(/probabilidad/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── ReportPage — empty state (sin simulación) ─────────────────────────────────
|
||||
|
||||
vi.mock('@/features/report/useReportData', () => ({
|
||||
useReportData: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useReportData } from '@/features/report/useReportData'
|
||||
import { ReportPage } from '@/features/report/ReportPage'
|
||||
|
||||
// Mock de TanStack Router para tests de ReportPage
|
||||
vi.mock('@tanstack/react-router', async (importActual) => {
|
||||
const actual = await importActual<typeof import('@tanstack/react-router')>()
|
||||
return {
|
||||
...actual,
|
||||
useParams: vi.fn().mockReturnValue({ processId: 'test-proc-id' }),
|
||||
useNavigate: vi.fn().mockReturnValue(vi.fn()),
|
||||
}
|
||||
})
|
||||
|
||||
describe('ReportPage — empty state (sin simulación)', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: null,
|
||||
simulation: null,
|
||||
activities: [],
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: 'Sin simulación disponible. Volvé al workspace y ejecutá "Simular" primero.',
|
||||
})
|
||||
})
|
||||
|
||||
it('muestra mensaje de error cuando no hay simulación', () => {
|
||||
render(<ReportPage />)
|
||||
expect(screen.getByText(/Sin simulación disponible/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('muestra botón para ir al workspace', () => {
|
||||
render(<ReportPage />)
|
||||
const btn = screen.getByText(/Volver a configurar y simular/i)
|
||||
expect(btn).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReportPage — loading state', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: null,
|
||||
simulation: null,
|
||||
activities: [],
|
||||
resources: [],
|
||||
isLoading: true,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('muestra skeleton de carga (no pantalla en blanco)', () => {
|
||||
render(<ReportPage />)
|
||||
// El skeleton usa animate-pulse en el contenedor
|
||||
const skeleton = document.querySelector('.animate-pulse')
|
||||
expect(skeleton).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReportPage — con datos completos', () => {
|
||||
const mockProcess = {
|
||||
id: 'test-proc-id',
|
||||
name: 'Proceso de Aprobación de Crédito',
|
||||
clientName: 'Banco ABC',
|
||||
bpmnXml: '<?xml version="1.0"?><definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"><process id="p"><startEvent id="s"><outgoing>f1</outgoing></startEvent><task id="t1" name="Task 1"><incoming>f1</incoming><outgoing>f2</outgoing></task><endEvent id="e"><incoming>f2</incoming></endEvent><sequenceFlow id="f1" sourceRef="s" targetRef="t1"/><sequenceFlow id="f2" sourceRef="t1" targetRef="e"/></process></definitions>',
|
||||
currency: 'USD',
|
||||
overheadPercentage: 0.2,
|
||||
createdAt: Date.now() - 1000 * 60 * 30,
|
||||
updatedAt: Date.now() - 1000 * 60 * 30,
|
||||
}
|
||||
|
||||
const mockSimulation = {
|
||||
id: 'sim-1',
|
||||
processId: 'test-proc-id',
|
||||
executedAt: Date.now() - 1000 * 60 * 10,
|
||||
result: mockResult,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: mockProcess,
|
||||
simulation: mockSimulation,
|
||||
activities: [],
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('renderiza el h1 con el nombre del proceso', () => {
|
||||
render(<ReportPage />)
|
||||
const h1 = screen.getByRole('heading', { level: 1 })
|
||||
expect(h1).toHaveTextContent('Proceso de Aprobación de Crédito')
|
||||
})
|
||||
|
||||
it('hay exactamente 4 KPI cards', () => {
|
||||
render(<ReportPage />)
|
||||
const cards = screen.getAllByTestId('kpi-card')
|
||||
expect(cards).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('hay al menos 2 gráficos ECharts (TopActivities + Donut siempre; CostByResource solo con recursos)', () => {
|
||||
render(<ReportPage />)
|
||||
const charts = screen.getAllByTestId('echarts-chart')
|
||||
// TopActivities y CostComposition siempre renderizan.
|
||||
// CostByResource muestra empty-state cuando resources=[] y sin breakdown.
|
||||
expect(charts.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('la tabla tiene N+1 rows (N actividades + 1 header)', () => {
|
||||
render(<ReportPage />)
|
||||
const rows = screen.getAllByRole('row')
|
||||
expect(rows).toHaveLength(mockActivities.length + 1)
|
||||
})
|
||||
|
||||
it('el nombre del cliente aparece en el reporte', () => {
|
||||
render(<ReportPage />)
|
||||
expect(screen.getByText(/Banco ABC/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('el timestamp de simulación es visible', () => {
|
||||
render(<ReportPage />)
|
||||
const text = document.body.textContent ?? ''
|
||||
// "Simulado el" debe aparecer en el header
|
||||
expect(text).toMatch(/Simulado el/i)
|
||||
})
|
||||
|
||||
it('no hay banner de warnings cuando no hay warnings', () => {
|
||||
render(<ReportPage />)
|
||||
expect(screen.queryByText(/Advertencia/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('con warnings: el banner aparece', () => {
|
||||
vi.mocked(useReportData).mockReturnValue({
|
||||
process: mockProcess,
|
||||
simulation: {
|
||||
...mockSimulation,
|
||||
result: { ...mockResult, warnings: ['Loop detectado en "Inspección"'] },
|
||||
},
|
||||
activities: [],
|
||||
resources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
render(<ReportPage />)
|
||||
expect(screen.getByText(/Loop detectado/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
293
tests/features/report/derivations.test.ts
Normal file
293
tests/features/report/derivations.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* 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: [],
|
||||
}])
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user