/** * Tests Etapa 7 — PDF ROI páginas 2-5 con identidad InQ. * * Verifica: * - Tabla (p.2): headStyles con fondo naranja claro, texto #92400E * - Análisis (p.3): títulos uppercase naranja oscuro, "COMPOSICIÓN DEL AHORRO" * - Metodología (p.4): título uppercase naranja oscuro, caja contenedora * - Gráfico SVG (p.5): líneas, áreas, marker payback, anotaciones * - Coordenadas del gráfico: inicio negativo, payback en 0, final positivo */ import { describe, it, expect, vi, beforeEach } from 'vitest' const mockDoc = { setProperties: vi.fn(), setFont: vi.fn(), setFontSize: vi.fn(), setTextColor: vi.fn(), setFillColor: vi.fn(), setDrawColor: vi.fn(), setLineWidth: vi.fn(), text: vi.fn(), rect: vi.fn(), roundedRect: vi.fn(), line: vi.fn(), addImage: vi.fn(), addPage: vi.fn(), setPage: vi.fn(), save: vi.fn(), splitTextToSize: vi.fn().mockImplementation((t: string) => [t]), lastAutoTable: { finalY: 150 }, internal: { getNumberOfPages: vi.fn().mockReturnValue(5), pageSize: { getWidth: vi.fn().mockReturnValue(210), getHeight: vi.fn().mockReturnValue(297), }, }, } vi.mock('jspdf', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any function MockJsPDF(this: any) { return mockDoc } return { jsPDF: MockJsPDF } }) vi.mock('jspdf-autotable', () => ({ default: vi.fn() })) import type { Process, Simulation, Activity } from '@/domain/types' import type { RoiResult } from '@/domain/roi' const mockProcess: Process = { id: 'p1', name: 'Proceso Test', clientName: 'Cliente Test', bpmnXml: '', currency: 'USD', overheadPercentage: 0.2, annualFrequency: 5000, analysisHorizonYears: 3, automationInvestment: 80000, createdAt: 0, updatedAt: 0, groupId: null, tags: [], ownerId: 'test-user', updatedBy: 'test-user', } const mockRoi: RoiResult = { savingsPerExecution: 540, savingsPerExecutionPercent: 90, annualSavings: 2_700_000, paybackMonths: 0.356, paybackYears: 0.03, cumulativeSavingsHorizon: 8_020_000, breaksEvenInHorizon: true, roiAnnualPercent: 3375, } const mockSimulation: Simulation = { id: 's1', processId: 'p1', executedAt: new Date('2026-05-17T10:00:00Z').getTime(), result: { totalCost: 600, totalDirectCost: 500, totalIndirectCost: 100, totalTimeMinutes: 90, perActivity: [ { activityId: 'a1', bpmnElementId: 't1', activityName: 'Tarea A', expectedDirectCost: 400, expectedIndirectCost: 80, expectedTotalCost: 400, percentOfTotal: 66, executionProbability: 1, expectedExecutions: 1, executionTimeMinutes: 30, resourceCostBreakdown: [] }, { activityId: 'a2', bpmnElementId: 't2', activityName: 'Tarea B', expectedDirectCost: 200, expectedIndirectCost: 40, expectedTotalCost: 200, percentOfTotal: 34, executionProbability: 1, expectedExecutions: 1, executionTimeMinutes: 20, resourceCostBreakdown: [] }, ], perResource: [], warnings: [], }, resultAutomated: { totalCost: 60, totalDirectCost: 50, totalIndirectCost: 10, totalTimeMinutes: 10, perActivity: [ { activityId: 'a1', bpmnElementId: 't1', activityName: 'Tarea A', expectedDirectCost: 40, expectedIndirectCost: 10, expectedTotalCost: 40, percentOfTotal: 66, executionProbability: 1, expectedExecutions: 1, executionTimeMinutes: 5, resourceCostBreakdown: [] }, { activityId: 'a2', bpmnElementId: 't2', activityName: 'Tarea B', expectedDirectCost: 20, expectedIndirectCost: 5, expectedTotalCost: 20, percentOfTotal: 34, executionProbability: 1, expectedExecutions: 1, executionTimeMinutes: 5, resourceCostBreakdown: [] }, ], perResource: [], warnings: [], }, } const mockActivities: Activity[] = [ { id: 'a1', processId: 'p1', bpmnElementId: 't1', name: 'Tarea A', type: 'task', automatable: true, directCostFixed: 400, executionTimeMinutes: 30, automatedCostFixed: 40, automatedTimeMinutes: 5, assignedResources: [], automatedAssignedResources: [] }, { id: 'a2', processId: 'p1', bpmnElementId: 't2', name: 'Tarea B', type: 'task', automatable: false, directCostFixed: 200, executionTimeMinutes: 20, automatedCostFixed: 0, automatedTimeMinutes: 0, assignedResources: [], automatedAssignedResources: [] }, ] describe('PDF ROI — Etapa 7 páginas 2-5', () => { beforeEach(() => { vi.clearAllMocks() mockDoc.internal.pageSize.getWidth.mockReturnValue(210) mockDoc.internal.pageSize.getHeight.mockReturnValue(297) mockDoc.internal.getNumberOfPages.mockReturnValue(5) mockDoc.splitTextToSize.mockImplementation((t: string) => [t]) }) // ─── Página 2: tabla comparativa ──────────────────────────────────────────── describe('buildComparisonTableConfig — header paleta InQ', () => { it('headStyles usa fondo #FFF8ED [255,248,237] (no naranja sólido)', async () => { const { buildComparisonTableConfig } = await import('@/lib/export/pdf-sections-roi') const config = buildComparisonTableConfig( mockSimulation.result.perActivity, mockSimulation.resultAutomated!.perActivity, mockActivities, 'USD', 50 ) as any expect(config.headStyles.fillColor).toEqual([255, 248, 237]) }) it('headStyles textColor es #92400E [146,64,14]', async () => { const { buildComparisonTableConfig } = await import('@/lib/export/pdf-sections-roi') const config = buildComparisonTableConfig( mockSimulation.result.perActivity, mockSimulation.resultAutomated!.perActivity, mockActivities, 'USD', 50 ) as any expect(config.headStyles.textColor).toEqual([146, 64, 14]) }) it('fila no-automatizable muestra "—" en columna Ahorro', async () => { const { buildComparisonTableConfig } = await import('@/lib/export/pdf-sections-roi') const config = buildComparisonTableConfig( mockSimulation.result.perActivity, mockSimulation.resultAutomated!.perActivity, mockActivities, 'USD', 50 ) as any // Tarea B es no-automatizable — debe tener "—" en ahorro (columna índice 3) const tareaB = (config.body as any[]).find((row: any[]) => row[0].content === 'Tarea B') expect(tareaB).toBeDefined() expect(tareaB[3].content).toBe('—') expect(tareaB[4].content).toBe('—') }) }) // ─── Página 3: análisis del ahorro ────────────────────────────────────────── describe('drawRoiAnalysisPage — títulos InQ', () => { it('dibuja "COMPOSICIÓN DEL AHORRO" (uppercase)', async () => { const { drawRoiAnalysisPage } = await import('@/lib/export/pdf-sections-roi') drawRoiAnalysisPage(mockDoc as any, mockRoi, mockProcess, mockSimulation.result.perActivity, mockSimulation.resultAutomated!.perActivity, mockActivities, 'USD', 50) const textCalls = mockDoc.text.mock.calls.map((c: unknown[]) => c[0]) expect(textCalls).toContain('COMPOSICIÓN DEL AHORRO') }) it('dibuja "TRAYECTORIA DEL AHORRO ACUMULADO" (uppercase)', async () => { const { drawRoiAnalysisPage } = await import('@/lib/export/pdf-sections-roi') drawRoiAnalysisPage(mockDoc as any, mockRoi, mockProcess, mockSimulation.result.perActivity, mockSimulation.resultAutomated!.perActivity, mockActivities, 'USD', 50) const textCalls = mockDoc.text.mock.calls.map((c: unknown[]) => c[0]) expect(textCalls).toContain('TRAYECTORIA DEL AHORRO ACUMULADO') }) it('dibuja nombre de actividad en bullets del top3', async () => { const { drawRoiAnalysisPage } = await import('@/lib/export/pdf-sections-roi') drawRoiAnalysisPage(mockDoc as any, mockRoi, mockProcess, mockSimulation.result.perActivity, mockSimulation.resultAutomated!.perActivity, mockActivities, 'USD', 50) const textCalls = mockDoc.text.mock.calls.map((c: unknown[]) => String(c[0])) const hasActivity = textCalls.some(t => t.includes('Tarea A') || t.includes('Tarea B')) expect(hasActivity).toBe(true) }) }) // ─── Página 4: nota metodológica ──────────────────────────────────────────── describe('drawRoiMethodologySection — paleta InQ', () => { it('dibuja título "NOTA METODOLOGICA" (uppercase, sin caracteres Unicode)', async () => { const { drawRoiMethodologySection } = await import('@/lib/export/pdf-sections-roi') drawRoiMethodologySection(mockDoc as any, mockProcess, 50) const textCalls = mockDoc.text.mock.calls.map((c: unknown[]) => c[0]) const hasTitle = textCalls.some(t => String(t).includes('NOTA METODOLOGICA')) expect(hasTitle).toBe(true) }) it('dibuja caja contenedora (rect con relleno slate-50)', async () => { const { drawRoiMethodologySection } = await import('@/lib/export/pdf-sections-roi') drawRoiMethodologySection(mockDoc as any, mockProcess, 50) // setFillColor para [248,250,252] slate-50 debe haberse llamado const fillCalls = mockDoc.setFillColor.mock.calls const hasSlate50 = fillCalls.some((c: number[]) => c[0] === 248 && c[1] === 250 && c[2] === 252) expect(hasSlate50).toBe(true) }) it('dibuja al menos 5 secciones (text calls para títulos de fórmulas)', async () => { const { drawRoiMethodologySection } = await import('@/lib/export/pdf-sections-roi') drawRoiMethodologySection(mockDoc as any, mockProcess, 50) expect(mockDoc.text.mock.calls.length).toBeGreaterThan(6) }) }) // ─── Página 5: gráfico SVG nativo ─────────────────────────────────────────── describe('drawTrajectoryChart — coordenadas matemáticas', () => { it('dibuja líneas (eje Y, eje X, segmentos de curva)', async () => { const { drawTrajectoryChart } = await import('@/lib/export/pdf-sections-roi') drawTrajectoryChart(mockDoc as any, mockRoi, mockProcess, 'USD', 20, 170, 50) expect(mockDoc.line.mock.calls.length).toBeGreaterThan(10) }) it('dibuja rectángulos de área (relleno para negativo y positivo)', async () => { const { drawTrajectoryChart } = await import('@/lib/export/pdf-sections-roi') drawTrajectoryChart(mockDoc as any, mockRoi, mockProcess, 'USD', 20, 170, 50) expect(mockDoc.rect.mock.calls.length).toBeGreaterThan(5) }) it('punto inicial (mes 0) tiene valor = -inversión', () => { // Con investment=80000 y annualSavings=2700000, valueAt(0) = -80000 const investment = 80000, annualSavings = 2_700_000 const valueAt = (month: number) => -investment + (annualSavings / 12) * month expect(valueAt(0)).toBe(-80000) }) it('punto de payback tiene valor = 0', () => { const investment = 80000, annualSavings = 2_700_000 const paybackMonths = investment / (annualSavings / 12) // = 0.3556 const valueAt = (month: number) => -investment + (annualSavings / 12) * month expect(valueAt(paybackMonths)).toBeCloseTo(0, 5) }) it('punto final (mes 36) tiene valor = cumulativeSavingsHorizon', () => { const investment = 80000, annualSavings = 2_700_000 const horizonMonths = 36 const valueAt = (month: number) => -investment + (annualSavings / 12) * month const finalValue = valueAt(horizonMonths) // annualSavings * 3 - investment = 8100000 - 80000 = 8020000 expect(finalValue).toBeCloseTo(8_020_000, 0) }) it('incluye texto de anotaciones (inversión y ahorro acumulado)', async () => { const { drawTrajectoryChart } = await import('@/lib/export/pdf-sections-roi') drawTrajectoryChart(mockDoc as any, mockRoi, mockProcess, 'USD', 20, 170, 50) // splitTextToSize se llama con el texto de anotaciones expect(mockDoc.splitTextToSize).toHaveBeenCalled() // text() se llama para etiquetas de ejes y anotaciones expect(mockDoc.text.mock.calls.length).toBeGreaterThan(4) }) it('sin payback en horizonte: no dibuja marker de payback', async () => { const { drawTrajectoryChart } = await import('@/lib/export/pdf-sections-roi') const roiSinPayback: RoiResult = { ...mockRoi, breaksEvenInHorizon: false, paybackMonths: Infinity, } // Solo verificar que no lanza error expect(() => drawTrajectoryChart(mockDoc as any, roiSinPayback, mockProcess, 'USD', 20, 170, 50) ).not.toThrow() }) }) // ─── exportToPdf ROI integración ──────────────────────────────────────────── describe('exportToPdf ROI — integración Etapa 7', () => { it('sigue siendo 5 páginas (4 addPage calls) sin regresión', async () => { const { exportToPdf } = await import('@/lib/export/pdf-export') await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'roi', activities: mockActivities, }) expect(mockDoc.addPage).toHaveBeenCalledTimes(4) }) }) })