/** * Tests de los componentes de ROI del reporte (Sprint 1 — Etapa 3). * * Cubre: RoiKpiBar, ComparisonTable, TransparencySection, TopSavingsChart, * CumulativeSavingsChart, y el comportamiento de tabs de ReportPage. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' 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), get: vi.fn().mockReturnValue({ width: 100 }) } if (svc === 'overlays') return { add: vi.fn(), remove: vi.fn() } 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 }) => (
), })) vi.mock('@/features/report/useReportData', () => ({ useReportData: vi.fn(), })) 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()), } }) vi.mock('@/auth/AuthContext', () => ({ useAuth: () => ({ user: { id: 'test-user', platformRole: 'platform_admin' } }), })) // ─── Fixtures ───────────────────────────────────────────────────────────────── import type { ActivitySimResult, Activity, SimulationResult } from '@/domain/types' import type { RoiResult } from '@/domain/roi' import { TooltipProvider } from '@/components/ui/tooltip' const makeActivity = (id: string, name: string, cost: number): ActivitySimResult => ({ activityId: id, bpmnElementId: `bpmn_${id}`, activityName: name, expectedDirectCost: cost * 0.83, expectedIndirectCost: cost * 0.17, expectedTotalCost: cost, percentOfTotal: 33, executionProbability: 1.0, expectedExecutions: 1.0, executionTimeMinutes: 60, resourceCostBreakdown: [], }) const perActivityActual: ActivitySimResult[] = [ makeActivity('a1', 'Recibir solicitud', 600), makeActivity('a2', 'Calcular score', 300), makeActivity('a3', 'Analizar riesgo', 100), ] const perActivityAutomated: ActivitySimResult[] = [ makeActivity('a1', 'Recibir solicitud', 60), // 90% ahorro makeActivity('a2', 'Calcular score', 30), // 90% ahorro makeActivity('a3', 'Analizar riesgo', 100), // sin cambio (no automatable) ] const mockResult: SimulationResult = { totalCost: 1000, totalDirectCost: 833, totalIndirectCost: 167, totalTimeMinutes: 180, perActivity: perActivityActual, perResource: [], warnings: [], } const mockResultAutomated: SimulationResult = { totalCost: 190, totalDirectCost: 158, totalIndirectCost: 32, totalTimeMinutes: 30, perActivity: perActivityAutomated, perResource: [], warnings: [], } const mockRoi: RoiResult = { savingsPerExecution: 810, savingsPerExecutionPercent: 81, annualSavings: 9_720_000, paybackMonths: 0.617, paybackYears: 0.051, cumulativeSavingsHorizon: 29_110_000, breaksEvenInHorizon: true, roiAnnualPercent: 19440, } const roiNoSavings: RoiResult = { savingsPerExecution: -50, savingsPerExecutionPercent: -10, annualSavings: -60_000, paybackMonths: Infinity, paybackYears: Infinity, cumulativeSavingsHorizon: -230_000, breaksEvenInHorizon: false, roiAnnualPercent: -60, } const roiZeroInvestment: RoiResult = { savingsPerExecution: 200, savingsPerExecutionPercent: 50, annualSavings: 240_000, paybackMonths: 0, paybackYears: 0, cumulativeSavingsHorizon: 720_000, breaksEvenInHorizon: true, roiAnnualPercent: Infinity, } // ─── RoiKpiBar ──────────────────────────────────────────────────────────────── import { RoiKpiBar } from '@/features/report/RoiKpiBar' describe('RoiKpiBar', () => { it('renderiza exactamente 5 ROI KPI cards', () => { render() expect(screen.getAllByTestId('roi-kpi-card')).toHaveLength(5) }) it('muestra el ahorro por ejecución formateado con moneda', () => { render() const text = document.body.textContent ?? '' // $810.00 debe aparecer expect(text).toMatch(/810/) }) it('payback Infinity → muestra "No recupera en horizonte"', () => { render() expect(screen.getByText(/No recupera en horizonte/i)).toBeInTheDocument() }) it('payback 0 (inversión cero) → muestra "Inmediato"', () => { render() expect(screen.getByText(/Inmediato/i)).toBeInTheDocument() }) it('ROI Infinity (inversión cero) → muestra "—" con texto accionable', () => { render() expect(screen.getByText('—')).toBeInTheDocument() expect(screen.getByText(/Configurá la inversión/i)).toBeInTheDocument() }) it('ahorro negativo: "breaksEvenInHorizon = false" → subtexto fuera del horizonte', () => { render() expect(screen.getByText(/Fuera del horizonte/i)).toBeInTheDocument() }) it('breaksEvenInHorizon = true → subtexto dentro del horizonte', () => { render() expect(screen.getByText(/Dentro del horizonte/i)).toBeInTheDocument() }) it('horizonYears se refleja en el label del KPI de acumulado', () => { render() const text = document.body.textContent ?? '' expect(text).toMatch(/5 años/) }) it('ningún KPI muestra NaN', () => { render() const text = document.body.textContent ?? '' expect(text).not.toContain('NaN') }) }) // ─── ComparisonTable ────────────────────────────────────────────────────────── import { ComparisonTable } from '@/features/report/ComparisonTable' const automatableIds = new Set(['bpmn_a1', 'bpmn_a2']) // a1 y a2 son automatizables const noAutomatableIds = new Set() describe('ComparisonTable', () => { it('renderiza N+1 rows (N actividades + 1 header)', () => { render( ) const rows = screen.getAllByRole('row') expect(rows).toHaveLength(perActivityActual.length + 1) }) it('actividades automatable muestran ícono Bot', () => { render( ) // a1 y a2 son automatable → 2 íconos Bot const botIcons = document.querySelectorAll('[aria-label="Marcada como automatizable"]') expect(botIcons).toHaveLength(2) }) it('actividades NO automatable NO muestran ícono Bot', () => { render( ) const botIcons = document.querySelectorAll('[aria-label="Marcada como automatizable"]') expect(botIcons).toHaveLength(0) }) it('ahorro de a1: 600 - 60 = 540 aparece en la tabla', () => { render( ) const text = document.body.textContent ?? '' expect(text).toMatch(/540/) }) it('tabla vacía: muestra mensaje sin actividades', () => { render( ) expect(screen.getByText(/Sin actividades para comparar/i)).toBeInTheDocument() }) it('ahorro negativo: se muestra sin prefijo +', () => { const negativeActual = [makeActivity('x1', 'Tarea cara auto', 100)] const negativeAuto = [makeActivity('x1', 'Tarea cara auto', 200)] // costo auto > actual render( ) // -100 de ahorro, sin prefijo '+' const text = document.body.textContent ?? '' expect(text).not.toMatch(/\+.*-/) }) it('sort toggle: clic en encabezado "Ahorro" invierte el orden', () => { render( ) // Buscar el th que contiene "Ahorro" exacto (no "Ahorro esperado" — hay múltiples // columnas cuyo texto incluye "Ahorro" desde Etapa 5) const headers = screen.getAllByRole('columnheader') const ahorroHeader = headers.find((h) => /^Ahorro$/i.test((h.textContent ?? '').trim())) expect(ahorroHeader).toBeDefined() // Sort por defecto es 'efficiency' (Etapa 5). Con esta fixture, executionProbability // y executionTimeMinutes son iguales en todas las filas, así que ordenar por // efficiency-desc y por savings-desc da el mismo orden visual (a1 primero). const rowsBefore = screen.getAllByRole('row').slice(1) const firstNameBefore = rowsBefore[0].textContent // Primer click: cambia la columna activa a 'savings' (desc) — el orden no cambia // para esta fixture porque coincide con el de 'efficiency'. fireEvent.click(ahorroHeader!) // Segundo click: misma columna ya activa → invierte a asc (a3/null al final, a2 primero) fireEvent.click(ahorroHeader!) const rowsAfter = screen.getAllByRole('row').slice(1) const firstNameAfter = rowsAfter[0].textContent expect(firstNameBefore).not.toBe(firstNameAfter) }) }) // ─── TransparencySection ────────────────────────────────────────────────────── import { TransparencySection } from '@/features/report/TransparencySection' const makeActivityDomain = (id: string, name: string, automatable: boolean, zeroCosts: boolean): Activity => ({ id, processId: 'p1', bpmnElementId: `bpmn_${id}`, name, type: 'task', directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [], automatable, automatedCostFixed: zeroCosts ? 0 : 50, automatedTimeMinutes: zeroCosts ? 0 : 10, automatedAssignedResources: [], }) describe('TransparencySection', () => { it('retorna null cuando no hay issues — no renderiza nada', () => { const { container } = render( ) expect(container.firstChild).toBeNull() }) it('Caso 1: actividad automatable con costos en cero → sección visible', () => { render( ) expect(screen.getByText(/Transparencia metodológica/i)).toBeInTheDocument() expect(screen.getByText('Facturación')).toBeInTheDocument() }) it('Caso 1: mensaje explica que el ahorro puede estar inflado', () => { render( ) expect(screen.getByText(/inflar artificialmente el ahorro/i)).toBeInTheDocument() }) it('Caso 1: plural correcto cuando hay múltiples actividades en cero', () => { render( ) expect(screen.getByText(/2 actividades automatizables sin costo configurado/i)).toBeInTheDocument() }) it('Caso 2: investment=0 → muestra aviso de inversión no configurada', () => { render( ) expect(screen.getByText(/Inversión en automatización no configurada/i)).toBeInTheDocument() expect(screen.getByText(/payback aparece como inmediato/i)).toBeInTheDocument() }) it('Caso 3: ambos issues coexisten — se muestran ambos mensajes en la misma sección', () => { render( ) expect(screen.getByText('Facturación')).toBeInTheDocument() expect(screen.getByText(/Inversión en automatización no configurada/i)).toBeInTheDocument() }) it('actividades no automatable no generan issue aunque tengan costos cero', () => { const { container } = render( ) expect(container.firstChild).toBeNull() }) // ─── Caso 4: ROI inusualmente alto ──────────────────────────────────────── const makeRoi = (roiAnnualPercent: number): RoiResult => ({ savingsPerExecution: 100, savingsPerExecutionPercent: 50, annualSavings: 1_200_000, paybackMonths: 0.5, paybackYears: 0.04, cumulativeSavingsHorizon: 3_550_000, breaksEvenInHorizon: true, roiAnnualPercent, }) it('Caso 4: ROI al 999% NO dispara el aviso de ROI inusual', () => { const { container } = render( ) // No hay otros issues → el componente no renderiza (999% no supera el umbral) expect(container.firstChild).toBeNull() }) it('Caso 4: ROI al 1001% SÍ dispara el aviso de ROI inusual', () => { render( ) expect(screen.getByText(/ROI inusualmente alto detectado/i)).toBeInTheDocument() // El texto incluye el valor formateado (>1000%) const text = document.body.textContent ?? '' expect(text).toMatch(/1\.001/) }) it('Caso 4: ROI Infinity (investment=0) NO dispara este aviso — lo cubre el caso 2', () => { const { container } = render( ) // El caso 2 (investment=0) sí aparece, pero NO el aviso de "ROI inusualmente alto" expect(screen.queryByText(/ROI inusualmente alto detectado/i)).not.toBeInTheDocument() // El caso 2 sí está presente expect(container.firstChild).not.toBeNull() expect(screen.getByText(/Inversión en automatización no configurada/i)).toBeInTheDocument() }) it('Caso 4: ROI negativo NO dispara el aviso de ROI inusual', () => { const { container } = render( ) expect(container.firstChild).toBeNull() expect(screen.queryByText(/ROI inusualmente alto detectado/i)).not.toBeInTheDocument() }) it('Caso 4 coexiste con Caso 1: ROI alto Y actividad con costo=0 → ambos mensajes', () => { render( 1000 y finite /> ) expect(screen.getByText('Facturación')).toBeInTheDocument() // Caso 1 expect(screen.getByText(/ROI inusualmente alto detectado/i)).toBeInTheDocument() // Caso 4 }) }) // ─── TopSavingsChart ────────────────────────────────────────────────────────── import { TopSavingsChart } from '@/features/report/TopSavingsChart' describe('TopSavingsChart', () => { it('muestra empty state cuando no hay ahorros positivos', () => { // perActivityAutomated con costos mayores → ahorro negativo const actualCosts = [makeActivity('a1', 'Tarea', 100)] const autoCosts = [makeActivity('a1', 'Tarea', 200)] render( ) expect(screen.getByText(/Sin actividades con ahorro positivo/i)).toBeInTheDocument() }) it('con ahorros positivos: renderiza el chart ECharts', () => { render( ) expect(screen.getByTestId('echarts-chart')).toBeInTheDocument() }) it('excluye actividades con ahorro cero o negativo del chart', () => { // a3 no tiene ahorro (costos iguales) → no debe aparecer en el chart // Solo a1 y a2 tienen ahorro positivo // Si renderiza chart → hay ≥1 actividad con ahorro > 0 render( ) // Chart renderizado → hay actividades con ahorro positivo expect(screen.getByTestId('echarts-chart')).toBeInTheDocument() }) it('con más de 5 actividades: sigue renderizando chart (top-5 filtrado internamente)', () => { const manyActual = Array.from({ length: 8 }, (_, i) => makeActivity(`a${i}`, `Tarea ${i}`, (i + 1) * 100) ) const manyAuto = Array.from({ length: 8 }, (_, i) => makeActivity(`a${i}`, `Tarea ${i}`, (i + 1) * 10) ) render( ) // No debe tirar error ni mostrar más de 5 en el chart (ECharts mockeado — no podemos inspeccionar option) expect(screen.getByTestId('echarts-chart')).toBeInTheDocument() }) }) // ─── CumulativeSavingsChart ─────────────────────────────────────────────────── import { CumulativeSavingsChart } from '@/features/report/CumulativeSavingsChart' describe('CumulativeSavingsChart', () => { it('renderiza el chart sin crash (payback dentro del horizonte)', () => { render( ) expect(screen.getByTestId('echarts-chart')).toBeInTheDocument() }) it('renderiza el chart sin crash cuando payback = Infinity (sin ahorro)', () => { render( ) expect(screen.getByTestId('echarts-chart')).toBeInTheDocument() }) it('renderiza el chart sin crash cuando investment = 0', () => { render( ) expect(screen.getByTestId('echarts-chart')).toBeInTheDocument() }) }) // ─── ReportPage — comportamiento de tabs ────────────────────────────────────── import { useReportData } from '@/features/report/useReportData' import { ReportPage } from '@/features/report/ReportPage' const mockProcessBase = { id: 'test-proc-id', name: 'Proceso Test', clientName: 'Cliente', bpmnXml: 'f1f1f2f2', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000, createdAt: Date.now() - 3600_000, updatedAt: Date.now() - 3600_000, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', orgId: null, } const mockSimulationBase = { id: 'sim-1', processId: 'test-proc-id', executedAt: Date.now() - 600_000, result: mockResult, } const automtableActivity: Activity = { id: 'act-1', processId: 'test-proc-id', bpmnElementId: 'bpmn_a1', name: 'Recibir solicitud', type: 'task', directCostFixed: 500, executionTimeMinutes: 60, assignedResources: [], automatable: true, automatedCostFixed: 50, automatedTimeMinutes: 5, automatedAssignedResources: [], } const nonAutomatableActivity: Activity = { ...automtableActivity, id: 'act-2', bpmnElementId: 'bpmn_a2', name: 'Procesar', automatable: false, } describe('ReportPage — comportamiento de tabs', () => { beforeEach(() => { vi.clearAllMocks() }) it('sin resultAutomated (snapshot viejo): tabs "Automatizado" y "ROI" están disabled', () => { vi.mocked(useReportData).mockReturnValue({ process: mockProcessBase, simulation: { ...mockSimulationBase }, // sin resultAutomated activities: [automtableActivity], resources: [], isLoading: false, error: null, }) render( ) const tabAuto = screen.getByRole('tab', { name: /automatizado/i }) const tabRoi = screen.getByRole('tab', { name: /comparación/i }) expect(tabAuto).toBeDisabled() expect(tabRoi).toBeDisabled() }) it('con resultAutomated pero SIN actividades automatizables: tabs disabled', () => { vi.mocked(useReportData).mockReturnValue({ process: mockProcessBase, simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated }, activities: [nonAutomatableActivity], // no hay automatable resources: [], isLoading: false, error: null, }) render( ) const tabAuto = screen.getByRole('tab', { name: /automatizado/i }) const tabRoi = screen.getByRole('tab', { name: /comparación/i }) expect(tabAuto).toBeDisabled() expect(tabRoi).toBeDisabled() }) it('con resultAutomated Y actividades automatizables: tabs habilitados', () => { vi.mocked(useReportData).mockReturnValue({ process: mockProcessBase, simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated }, activities: [automtableActivity], // hay 1 automatable resources: [], isLoading: false, error: null, }) render( ) const tabAuto = screen.getByRole('tab', { name: /automatizado/i }) const tabRoi = screen.getByRole('tab', { name: /comparación/i }) expect(tabAuto).not.toBeDisabled() expect(tabRoi).not.toBeDisabled() }) it('tab "Actual" activo por defecto: muestra los 4 KPI cards del costo actual', () => { vi.mocked(useReportData).mockReturnValue({ process: mockProcessBase, simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated }, activities: [automtableActivity], resources: [], isLoading: false, error: null, }) render( ) // Tab "Actual" activo por defecto → 4 kpi-cards (no 5 roi-kpi-cards) expect(screen.getAllByTestId('kpi-card')).toHaveLength(4) expect(screen.queryByTestId('roi-kpi-card')).toBeNull() }) it('click en tab "Comparación + ROI": muestra 5 ROI KPI cards', async () => { const user = userEvent.setup() vi.mocked(useReportData).mockReturnValue({ process: mockProcessBase, simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated }, activities: [automtableActivity], resources: [], isLoading: false, error: null, }) render( ) await user.click(screen.getByRole('tab', { name: /comparación/i })) await waitFor(() => expect(screen.getAllByTestId('roi-kpi-card')).toHaveLength(5)) }) it('click en tab "Comparación + ROI": la tabla de comparación tiene N+1 rows', async () => { const user = userEvent.setup() vi.mocked(useReportData).mockReturnValue({ process: mockProcessBase, simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated }, activities: [automtableActivity], resources: [], isLoading: false, error: null, }) render( ) await user.click(screen.getByRole('tab', { name: /comparación/i })) // mockResult tiene 3 actividades → 3 data rows + 1 header await waitFor(() => expect(screen.getAllByRole('row')).toHaveLength(perActivityActual.length + 1)) }) it('click en tab "Comparación + ROI" con investment=0: TransparencySection visible', async () => { const user = userEvent.setup() vi.mocked(useReportData).mockReturnValue({ process: { ...mockProcessBase, automationInvestment: 0 }, simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated }, activities: [automtableActivity], resources: [], isLoading: false, error: null, }) render( ) await user.click(screen.getByRole('tab', { name: /comparación/i })) await waitFor(() => expect(screen.getByText(/Transparencia metodológica/i)).toBeInTheDocument()) }) it('tab "Automatizado": muestra los 4 KPI cards del escenario automatizado', () => { vi.mocked(useReportData).mockReturnValue({ process: mockProcessBase, simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated }, activities: [automtableActivity], resources: [], isLoading: false, error: null, }) render( ) fireEvent.click(screen.getByRole('tab', { name: /automatizado/i })) // El tab automatizado también muestra 4 kpi-cards (del escenario automatizado) const kpiCards = screen.getAllByTestId('kpi-card') // Puede haber 4 del tab actual (ocultos) + 4 del tab automatizado = 8 // O solo 4 si Radix desmonta el tab inactivo expect(kpiCards.length).toBeGreaterThanOrEqual(4) }) it('tab "Automatizado": la nota de AutomationScenarioNote está presente', async () => { const user = userEvent.setup() vi.mocked(useReportData).mockReturnValue({ process: mockProcessBase, simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated }, activities: [automtableActivity], resources: [], isLoading: false, error: null, }) render( ) await user.click(screen.getByRole('tab', { name: /automatizado/i })) await waitFor(() => expect(screen.getByText(/actividades no marcadas como automatizables/i)).toBeInTheDocument() ) }) })