/** * 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 }) => (
), })) // ─── 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() const cards = screen.getAllByTestId('kpi-card') expect(cards).toHaveLength(4) }) it('la primera card muestra el costo total formateado', () => { render() // 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()).not.toThrow() const cards = screen.getAllByTestId('kpi-card') expect(cards).toHaveLength(4) }) it('tiempo total aparece formateado en minutos/horas', () => { render() // 105 min = 1h 45min const text = document.body.textContent ?? '' expect(text).toMatch(/1h|105/) }) it('cantidad de actividades es visible', () => { render() 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() expect(container.firstChild).toBeNull() }) it('renderiza el banner cuando hay warnings', () => { render() expect(screen.getByText(/Loop detectado/)).toBeInTheDocument() }) it('con múltiples warnings: muestra todos', () => { render() 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() expect(screen.getByText(/Impacto en los resultados/i)).toBeInTheDocument() }) it('warning sin loop: NO muestra nota de impacto', () => { render() 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( ) // 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( ) 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( ) 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( ) 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( ) 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: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0, createdAt: 0, updatedAt: 0, groupId: null, tags: [], } expect(() => render()).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: [], } render() 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: [], } render() 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() 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() expect(screen.getByText(/Sin simulación disponible/i)).toBeInTheDocument() }) it('muestra botón para ir al workspace', () => { render() 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() // 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: 'f1f1f2f2', 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: [], } 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() const h1 = screen.getByRole('heading', { level: 1 }) expect(h1).toHaveTextContent('Proceso de Aprobación de Crédito') }) it('hay exactamente 4 KPI cards', () => { render() 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() 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() const rows = screen.getAllByRole('row') expect(rows).toHaveLength(mockActivities.length + 1) }) it('el nombre del cliente aparece en el reporte', () => { render() expect(screen.getByText(/Banco ABC/)).toBeInTheDocument() }) it('el timestamp de simulación es visible', () => { render() 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() 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() expect(screen.getByText(/Loop detectado/)).toBeInTheDocument() }) })