feat(sprint-2): recursos por actividad — modelo, UX y PDF
Etapas 3-6 del Sprint 2. Resumen de cambios:
**Reporte web (Etapa 3)**
- ActivitiesTable: filas expandibles con desglose fijo + recursos por actividad
- CumulativeSavingsChart: fix payback label superpuesto con eje Y
- BpmnCanvas: badge ⚡ 14→20px
- TECH_DEBT cerrado: chart -inversión (ya estaba), payback label, badge
**Workspace (Etapa 4)**
- WorkspacePage: edición inline de nombre de proceso y cliente
(click → input → Enter/blur guarda en IndexedDB, checkmark naranja 1.5s)
**PDF (Etapas 5-6)**
- pdf-sections: drawResourcesSection — tabla condicional de recursos por actividad
con chips de tipo (rol/persona/sistema/equipamiento/insumo)
- pdf-export: página de recursos (pág. 4 cuando hay datos, metodología pasa a pág. 5)
- Fix truncamiento: splitTextToSize en resourceName y top3 ROI
- Fix texto: "ejecución" con tilde, header "Esp. (USD)" sin truncar
- TECH_DEBT cerrado: truncamiento notas metodológicas
**Tests**
- 7 tests nuevos: drawResourcesSection (4) + comportamiento condicional PDF (3)
- Total: 541 → 548 tests verdes
BREAKING: exportScenarioPdf ahora recibe activities[] como parámetro adicional.
This commit is contained in:
154
tests/lib/export/pdf-export-sprint2.test.ts
Normal file
154
tests/lib/export/pdf-export-sprint2.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Tests de comportamiento condicional del PDF de escenario con recursos (Sprint 2).
|
||||
* Verifica que la página extra de recursos aparece (o no) según los datos.
|
||||
*
|
||||
* Usa el mismo patrón de mocks que pdf-export.test.ts.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// ─── Mock de jsPDF ────────────────────────────────────────────────────────────
|
||||
|
||||
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((text: string) => [text]),
|
||||
internal: {
|
||||
getNumberOfPages: vi.fn().mockReturnValue(3),
|
||||
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(),
|
||||
}))
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { Process, Simulation, Resource, Activity } from '@/domain/types'
|
||||
|
||||
const mockProcess: Process = {
|
||||
id: 'p1', name: 'Test Process', clientName: 'Test Client',
|
||||
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
|
||||
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
|
||||
createdAt: 0, updatedAt: 0,
|
||||
}
|
||||
|
||||
// Simulación SIN recursos en ninguna actividad
|
||||
const mockSimNoResources: Simulation = {
|
||||
id: 'sim1', processId: 'p1',
|
||||
executedAt: new Date('2026-05-13T10:00:00Z').getTime(),
|
||||
result: {
|
||||
totalCost: 1000, totalDirectCost: 800, totalIndirectCost: 200,
|
||||
totalTimeMinutes: 60,
|
||||
perActivity: [{
|
||||
activityId: 'a1', bpmnElementId: 'task1', activityName: 'Tarea A',
|
||||
expectedDirectCost: 800, expectedIndirectCost: 200, expectedTotalCost: 1000,
|
||||
percentOfTotal: 100, executionProbability: 1.0, expectedExecutions: 1.0,
|
||||
executionTimeMinutes: 60, resourceCostBreakdown: [],
|
||||
}],
|
||||
perResource: [], warnings: [],
|
||||
},
|
||||
}
|
||||
|
||||
// Simulación CON recursos en una actividad
|
||||
const mockResource: Resource = {
|
||||
id: 'r1', processId: 'p1', name: 'Analista', type: 'role', costPerHour: 60,
|
||||
}
|
||||
|
||||
const mockActivity: Activity = {
|
||||
id: 'a1', processId: 'p1', bpmnElementId: 'task1', name: 'Tarea A', type: 'task',
|
||||
directCostFixed: 200, executionTimeMinutes: 60,
|
||||
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 60, units: 1 }],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
}
|
||||
|
||||
const mockSimWithResources: Simulation = {
|
||||
id: 'sim2', processId: 'p1',
|
||||
executedAt: new Date('2026-05-13T10:00:00Z').getTime(),
|
||||
result: {
|
||||
totalCost: 1000, totalDirectCost: 800, totalIndirectCost: 200,
|
||||
totalTimeMinutes: 60,
|
||||
perActivity: [{
|
||||
activityId: 'a1', bpmnElementId: 'task1', activityName: 'Tarea A',
|
||||
expectedDirectCost: 860, expectedIndirectCost: 140, expectedTotalCost: 1000,
|
||||
percentOfTotal: 100, executionProbability: 1.0, expectedExecutions: 1.0,
|
||||
executionTimeMinutes: 60,
|
||||
resourceCostBreakdown: [{ resourceId: 'r1', resourceName: 'Analista', cost: 60 }],
|
||||
}],
|
||||
perResource: [], warnings: [],
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('exportToPdf — página condicional de recursos (Sprint 2)', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('NO llama a drawResourcesSection cuando ninguna actividad tiene recursos', async () => {
|
||||
const autoTable = (await import('jspdf-autotable')).default as ReturnType<typeof vi.fn>
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
|
||||
await exportToPdf({
|
||||
process: mockProcess,
|
||||
simulation: mockSimNoResources,
|
||||
resources: [],
|
||||
heatmapImageData: null,
|
||||
})
|
||||
|
||||
// Sin recursos: autoTable se llama exactamente 1 vez (tabla principal de actividades)
|
||||
expect(autoTable).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('llama a autoTable dos veces cuando hay actividades con recursos', async () => {
|
||||
const autoTable = (await import('jspdf-autotable')).default as ReturnType<typeof vi.fn>
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
|
||||
await exportToPdf({
|
||||
process: mockProcess,
|
||||
simulation: mockSimWithResources,
|
||||
resources: [mockResource],
|
||||
heatmapImageData: null,
|
||||
activities: [mockActivity],
|
||||
})
|
||||
|
||||
// Con recursos: tabla principal + tabla de recursos = 2 llamadas
|
||||
expect(autoTable).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('addPage se llama una vez más cuando hay recursos (página extra)', async () => {
|
||||
const { exportToPdf } = await import('@/lib/export/pdf-export')
|
||||
|
||||
await exportToPdf({
|
||||
process: mockProcess,
|
||||
simulation: mockSimNoResources,
|
||||
resources: [],
|
||||
heatmapImageData: null,
|
||||
})
|
||||
const addPageCallsWithout = mockDoc.addPage.mock.calls.length
|
||||
|
||||
vi.clearAllMocks()
|
||||
|
||||
await exportToPdf({
|
||||
process: mockProcess,
|
||||
simulation: mockSimWithResources,
|
||||
resources: [mockResource],
|
||||
heatmapImageData: null,
|
||||
activities: [mockActivity],
|
||||
})
|
||||
const addPageCallsWith = mockDoc.addPage.mock.calls.length
|
||||
|
||||
expect(addPageCallsWith).toBe(addPageCallsWithout + 1)
|
||||
})
|
||||
})
|
||||
124
tests/lib/export/pdf-resources-section.test.ts
Normal file
124
tests/lib/export/pdf-resources-section.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Tests unitarios de drawResourcesSection (Sprint 2, Etapa 6).
|
||||
*
|
||||
* drawResourcesSection toma doc: any y autoTable: Function directamente,
|
||||
* por lo que no necesita mock de jspdf — se pasa un objeto plano.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { ActivitySimResult, Resource, Activity } from '@/domain/types'
|
||||
|
||||
// ─── Mock doc (jsPDF API mínima usada por drawResourcesSection) ───────────────
|
||||
|
||||
const mockDoc = {
|
||||
setFont: vi.fn(),
|
||||
setFontSize: vi.fn(),
|
||||
setTextColor: vi.fn(),
|
||||
setDrawColor: vi.fn(),
|
||||
setLineWidth: vi.fn(),
|
||||
text: vi.fn(),
|
||||
line: vi.fn(),
|
||||
}
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockResource: Resource = {
|
||||
id: 'r1', processId: 'p1', name: 'Analista Senior', type: 'role', costPerHour: 60,
|
||||
}
|
||||
|
||||
const mockActivity: Activity = {
|
||||
id: 'a1', processId: 'p1', bpmnElementId: 'task1', name: 'Recibir solicitud', type: 'task',
|
||||
directCostFixed: 600, executionTimeMinutes: 60,
|
||||
assignedResources: [{ resourceId: 'r1', utilizationMinutes: 60, units: 1 }],
|
||||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||||
}
|
||||
|
||||
const activityWithResources: ActivitySimResult = {
|
||||
activityId: 'a1', bpmnElementId: 'task1', activityName: 'Recibir solicitud',
|
||||
expectedDirectCost: 660, expectedIndirectCost: 0, expectedTotalCost: 660,
|
||||
percentOfTotal: 100, executionProbability: 1.0, expectedExecutions: 1.0,
|
||||
executionTimeMinutes: 60,
|
||||
resourceCostBreakdown: [{ resourceId: 'r1', resourceName: 'Analista Senior', cost: 60 }],
|
||||
}
|
||||
|
||||
const activityWithoutResources: ActivitySimResult = {
|
||||
activityId: 'a2', bpmnElementId: 'task2', activityName: 'Archivar',
|
||||
expectedDirectCost: 100, expectedIndirectCost: 0, expectedTotalCost: 100,
|
||||
percentOfTotal: 0, executionProbability: 1.0, expectedExecutions: 1.0,
|
||||
executionTimeMinutes: 10, resourceCostBreakdown: [],
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('drawResourcesSection', () => {
|
||||
let autoTableMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
autoTableMock = vi.fn()
|
||||
})
|
||||
|
||||
it('no lanza error con actividades que tienen recursos', async () => {
|
||||
const { drawResourcesSection } = await import('@/lib/export/pdf-sections')
|
||||
expect(() =>
|
||||
drawResourcesSection(
|
||||
mockDoc,
|
||||
autoTableMock,
|
||||
{ perActivity: [activityWithResources] },
|
||||
[mockResource],
|
||||
[mockActivity],
|
||||
'USD',
|
||||
40
|
||||
)
|
||||
).not.toThrow()
|
||||
expect(autoTableMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('no llama a autoTable si todas las actividades tienen resourceCostBreakdown vacío', async () => {
|
||||
const { drawResourcesSection } = await import('@/lib/export/pdf-sections')
|
||||
drawResourcesSection(
|
||||
mockDoc,
|
||||
autoTableMock,
|
||||
{ perActivity: [activityWithoutResources] },
|
||||
[mockResource],
|
||||
[mockActivity],
|
||||
'USD',
|
||||
40
|
||||
)
|
||||
expect(autoTableMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('el header de autoTable incluye la moneda correcta', async () => {
|
||||
const { drawResourcesSection } = await import('@/lib/export/pdf-sections')
|
||||
drawResourcesSection(
|
||||
mockDoc,
|
||||
autoTableMock,
|
||||
{ perActivity: [activityWithResources] },
|
||||
[mockResource],
|
||||
[mockActivity],
|
||||
'PYG',
|
||||
40
|
||||
)
|
||||
const callArgs = autoTableMock.mock.calls[0][1] as { head: unknown[][] }
|
||||
const headFlat = JSON.stringify(callArgs.head)
|
||||
expect(headFlat).toContain('PYG')
|
||||
})
|
||||
|
||||
it('llama a autoTable exactamente una vez aunque haya varias actividades con recursos', async () => {
|
||||
const { drawResourcesSection } = await import('@/lib/export/pdf-sections')
|
||||
const secondActivity: ActivitySimResult = {
|
||||
...activityWithResources,
|
||||
activityId: 'a3', activityName: 'Evaluar solicitud',
|
||||
resourceCostBreakdown: [{ resourceId: 'r1', resourceName: 'Analista Senior', cost: 30 }],
|
||||
}
|
||||
drawResourcesSection(
|
||||
mockDoc,
|
||||
autoTableMock,
|
||||
{ perActivity: [activityWithResources, secondActivity] },
|
||||
[mockResource],
|
||||
[mockActivity],
|
||||
'USD',
|
||||
40
|
||||
)
|
||||
expect(autoTableMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user