425 lines
15 KiB
TypeScript
425 lines
15 KiB
TypeScript
/**
|
|
* 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('@/components/AppHeader', () => ({ AppHeader: () => null }))
|
|
|
|
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="percentile"
|
|
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="percentile"
|
|
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="percentile"
|
|
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="percentile"
|
|
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="percentile"
|
|
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, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
|
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user',
|
|
}
|
|
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, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
|
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user',
|
|
}
|
|
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, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
|
createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user',
|
|
}
|
|
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()),
|
|
}
|
|
})
|
|
|
|
vi.mock('@/auth/AuthContext', () => ({
|
|
useAuth: () => ({ user: { id: 'test-user', platformRole: 'platform_admin' } }),
|
|
}))
|
|
|
|
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,
|
|
annualFrequency: 1000,
|
|
analysisHorizonYears: 3,
|
|
automationInvestment: 0,
|
|
createdAt: Date.now() - 1000 * 60 * 30,
|
|
updatedAt: Date.now() - 1000 * 60 * 30,
|
|
groupId: null,
|
|
tags: [],
|
|
ownerId: 'test-user', updatedBy: 'test-user',
|
|
}
|
|
|
|
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()
|
|
})
|
|
})
|