Files
inq-roi-simulador-web/tests/features/report/components.test.tsx
Marcos Benítez d6e7226842 feat(process-repo): extender fromRow/toRow/getById con areaId y areaName
- fromRow: extrae areaId y areaName (vía JOIN en getById)
- toRow y updateEditable: incluyen area_id en escrituras a Supabase
- getById: JOIN con areas!area_id(name) para obtener nombre en una query
- Fixtures de tests actualizados con areaId: null (campo requerido)
- 4 tests nuevos en tests/persistence/area-repo.test.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 18:18:14 -03:00

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', orgId: null, areaId: null,
}
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', orgId: null, areaId: null,
}
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', orgId: null, areaId: null,
}
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', orgId: null, areaId: null,
}
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()
})
})