"chore(claude-code): configurar permisos compartidos del proyecto
- settings.json: deny patterns críticos (rm -rf root, force push, etc.) - settings.local.json en gitignore — cada dev configura sus allows Refs: methodology/PATTERNS.md B.4 (reglas no negociables en repo)
This commit is contained in:
14
.claude/settings.json
Normal file
14
.claude/settings.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"deny": [
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf /*)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(rm -rf ~/*)",
|
||||
"Bash(git push --force *)",
|
||||
"Bash(git reset --hard *)",
|
||||
"Bash(git clean -fdx *)",
|
||||
"Bash(npm publish *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@ test-results/
|
||||
|
||||
# Wrangler
|
||||
.wrangler/
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -307,5 +307,13 @@ Cuando detectés una oportunidad de mejora fuera del scope del prompt actual:
|
||||
- Cambios de marca, arquitectura o datos (paleta, dependencias,
|
||||
estructura, features nuevas): CONSULTAR ANTES DE PROCEDER
|
||||
|
||||
Estructura de propuesta cuando hay un cambio Nivel 3:
|
||||
|
||||
PROPUESTA DE CAMBIO NIVEL 3 — requiere aprobación antes de implementar
|
||||
Cambio sugerido: [descripción]
|
||||
Justificación: [por qué lo considero valioso]
|
||||
Impacto: [qué afecta]
|
||||
Alternativa si se rechaza: [qué hacer en lugar]
|
||||
|
||||
Ver methodology/PRINCIPLES.md sección "Política de iniciativa agéntica"
|
||||
para detalle completo.
|
||||
311
sprints/sprint-1-5/ETAPA_4_PROMPT.md
Normal file
311
sprints/sprint-1-5/ETAPA_4_PROMPT.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# Sprint 1.5 — Etapa 4: PDF — Header y footer compartidos
|
||||
|
||||
> **Objetivo:** refactor estructural de los headers y footers de PDFs. Crear módulo compartido que aplique identidad InQ consistente en los 3 PDFs (Actual, Automatizado, ROI). Aplicar paleta naranja InQ a elementos de marca. NO rediseñar layout de contenido — eso es Etapa 5+.
|
||||
>
|
||||
> **Pre-requisitos:** Etapas 1, 2 y 3 cerradas. Sistema de diseño con tokens InQ disponible. Web app con identidad InQ aplicada.
|
||||
>
|
||||
> **Etapa siguiente:** Etapa 5 — PDF: layout híbrido portrait + landscape (la página BPMN pasa a horizontal).
|
||||
|
||||
---
|
||||
|
||||
## 1. Alcance estricto de Etapa 4
|
||||
|
||||
### Sí entra
|
||||
- ✅ Crear módulo compartido `pdf-sections-shared.ts` con `drawHeader` y `drawFooter` reutilizables
|
||||
- ✅ Refactorizar los 3 PDFs existentes (Actual, Automatizado, ROI) para usar el módulo compartido
|
||||
- ✅ Aplicar paleta naranja InQ en:
|
||||
- Border-bottom del header (color naranja `#F59845`, grosor variable según página)
|
||||
- Logo "InQ ROI" en color naranja en cada header
|
||||
- "Powered by InQuality" en color slate-500 en cada footer
|
||||
- ✅ Reemplazar `PDF.blue` por `PDF.inqOrange` en headers de tablas autotable (de pdf-sections.ts, pdf-export.ts, pdf-sections-roi.ts según audit Etapa 1)
|
||||
- ✅ Quitar versión "v0.1.0" del footer del PDF (decisión de Etapa 3 — la versión se queda solo en web app)
|
||||
- ✅ Tests E2E confirman que los 3 PDFs siguen generándose correctamente con la nueva estructura
|
||||
|
||||
### NO entra
|
||||
- ❌ Cambio de orientación de páginas (Etapa 5)
|
||||
- ❌ Rediseño de página 1 ROI con KPI Hero gradiente (Etapa 6)
|
||||
- ❌ Tabla comparativa rediseñada con paleta (Etapa 7)
|
||||
- ❌ Top 3 ahorros en página 1 ROI (Etapa 6)
|
||||
- ❌ Sección Transparencia rediseñada (Etapa 7)
|
||||
- ❌ Modificar el contenido de cualquier página más allá del header/footer
|
||||
|
||||
**Si encontrás algo que parece "natural" agregar pero no está en "Sí entra", PARÁ y consultá. Aplicá la política de iniciativa proactiva de CLAUDE.md.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Decisiones ya tomadas (no consultar de nuevo)
|
||||
|
||||
| Decisión | Justificación |
|
||||
|---|---|
|
||||
| Logo "InQ ROI" en header de cada página | Brief sección 2.6, ya aplicado en Etapa 2 |
|
||||
| "Powered by InQuality" en footer de cada página | Regla de marca CLAUDE.md, ya aplicado en Etapa 2 |
|
||||
| Border-bottom 3px naranja en página 1, 2px en páginas siguientes | Brief sección 2.6 |
|
||||
| Quitar versión del PDF (mantener solo en web) | Decisión de Etapa 3 |
|
||||
| Color naranja InQ `#F59845` para elementos de marca | Manual de marca Etapa 1 |
|
||||
| Mantener fuente actual (Roboto o equivalente) | No cambiar de fuente en esta etapa |
|
||||
| Caracteres especiales (✓, ⚡, ⚠) reemplazados por equivalentes ASCII si la fuente no soporta | Decisión Etapa 2 — se mantiene |
|
||||
|
||||
---
|
||||
|
||||
## 3. Estructura del módulo compartido
|
||||
|
||||
### 3.1 Archivo nuevo: `src/lib/export/pdf-sections-shared.ts`
|
||||
|
||||
Debe exportar al menos estas dos funciones:
|
||||
|
||||
```typescript
|
||||
import type { jsPDF } from 'jspdf';
|
||||
|
||||
export interface PdfHeaderOptions {
|
||||
pageNumber: number; // 1, 2, 3...
|
||||
totalPages: number; // necesario para "Página X de Y"
|
||||
productName: string; // "InQ ROI"
|
||||
sectionTitle?: string; // ej. "Análisis de retorno de inversión"
|
||||
clientName?: string; // ej. "Banco Atlántico S.A."
|
||||
processName?: string; // ej. "medium-with-gateways"
|
||||
simulatedAt: Date; // fecha de simulación
|
||||
}
|
||||
|
||||
export interface PdfFooterOptions {
|
||||
pageNumber: number;
|
||||
totalPages: number;
|
||||
productName: string; // "InQ ROI"
|
||||
clientName?: string;
|
||||
simulatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dibuja el header en la posición actual del documento.
|
||||
* - Página 1: border-bottom 3px naranja
|
||||
* - Otras páginas: border-bottom 2px naranja
|
||||
* - Logo "InQ ROI" en naranja InQ (esquina superior izquierda o derecha según diseño)
|
||||
* - Metadata del producto/proceso a la derecha (ver brief sección 2.6)
|
||||
*/
|
||||
export function drawHeader(doc: jsPDF, options: PdfHeaderOptions): void;
|
||||
|
||||
/**
|
||||
* Dibuja el footer en la posición fija inferior (16-20px del bottom).
|
||||
* - Border-top 0.5px slate-200
|
||||
* - Izquierda: "InQ ROI · Powered by InQuality · [cliente] · [fecha]"
|
||||
* - Derecha: "Página X de Y"
|
||||
* - NO incluir versión del producto
|
||||
*/
|
||||
export function drawFooter(doc: jsPDF, options: PdfFooterOptions): void;
|
||||
|
||||
/**
|
||||
* Constantes de paleta para PDFs.
|
||||
*/
|
||||
export const PDF_COLORS = {
|
||||
inqOrange: [245, 152, 69], // #F59845
|
||||
inqOrangeDark: [180, 83, 9], // #B45309
|
||||
inqMagenta: [237, 62, 143], // #ED3E8F
|
||||
textPrimary: [15, 23, 42], // #0F172A
|
||||
textSecondary: [71, 85, 105], // #475569
|
||||
textTertiary: [148, 163, 184], // #94A3B8
|
||||
borderLight: [226, 232, 240], // #E2E8F0
|
||||
// ... cualquier otro que necesites
|
||||
} as const;
|
||||
```
|
||||
|
||||
### 3.2 Eliminar `PDF.blue` del código
|
||||
|
||||
Buscar todas las ocurrencias y reemplazar por `PDF_COLORS.inqOrange`:
|
||||
|
||||
```bash
|
||||
grep -rn "PDF.blue\|PDF\.blue" src/
|
||||
```
|
||||
|
||||
Archivos afectados según audit Etapa 1:
|
||||
- `pdf-sections.ts:9` (definición de la constante — eliminar)
|
||||
- `pdf-sections.ts:112` (uso en KPI card accent)
|
||||
- `pdf-export.ts:120` (header de tabla autotable)
|
||||
- `pdf-sections-roi.ts:203` (header de tabla comparativa ROI)
|
||||
|
||||
### 3.3 Refactorizar los 3 PDFs
|
||||
|
||||
Los archivos que generan los PDFs deben:
|
||||
1. Importar `drawHeader`, `drawFooter`, `PDF_COLORS` del módulo compartido
|
||||
2. Reemplazar el código actual de header/footer por las llamadas al módulo
|
||||
3. Mantener todo el resto del contenido sin cambios (esto NO es Etapa 5-7)
|
||||
|
||||
Archivos a refactorizar:
|
||||
- `src/lib/export/pdf-export.ts` (PDFs Actual y Automatizado)
|
||||
- `src/lib/export/pdf-sections-roi.ts` (PDF de Comparación + ROI)
|
||||
- Cualquier helper que genere los PDFs anteriores
|
||||
|
||||
---
|
||||
|
||||
## 4. Validación visual mandatoria
|
||||
|
||||
### 4.1 Antes de declarar etapa completada
|
||||
|
||||
Generar los 3 PDFs con el setup realista de pruebas y validarlos visualmente:
|
||||
|
||||
**Setup de referencia:**
|
||||
- Proceso: `medium-with-gateways`
|
||||
- 3 actividades automatizables (`task_recv`, `task_score`, `task_analisis`)
|
||||
- Costos: directos $200, automatizados $20
|
||||
- Volumetría: 5000/año, 3 años, USD 80.000 inversión
|
||||
|
||||
**Archivos a generar en `tests/e2e/__output__/`:**
|
||||
- `etapa-4-actual.pdf`
|
||||
- `etapa-4-automatizado.pdf`
|
||||
- `etapa-4-roi.pdf`
|
||||
|
||||
### 4.2 Validaciones automatizadas mandatorias
|
||||
|
||||
Antes de `present_files`, ejecutá y confirmá:
|
||||
|
||||
```
|
||||
Para cada PDF generado:
|
||||
|
||||
1. pdf-parse confirma palabras clave en TODAS las páginas:
|
||||
- "InQ ROI" presente
|
||||
- "Powered by InQuality" presente en footer
|
||||
|
||||
2. pdf-parse rechaza términos prohibidos:
|
||||
- "Process Cost Platform" — NO debe aparecer
|
||||
- "v0.1.0" — NO debe aparecer en el PDF (solo en web)
|
||||
- "INQ", "Inq", "inq" — NO debe aparecer (regla de marca I-n-Q)
|
||||
|
||||
3. Cantidad de páginas:
|
||||
- actual.pdf: 3 páginas
|
||||
- automatizado.pdf: 3 páginas
|
||||
- roi.pdf: 4 páginas
|
||||
|
||||
4. Tamaño de cada página: entre 15-80 KB
|
||||
Si alguna queda <15KB: anomalía, investigar antes de presentar
|
||||
|
||||
5. Análisis de píxeles del border-bottom del header:
|
||||
- Confirmar presencia de naranja #F59845 (al menos N píxeles)
|
||||
- Ausencia de azul `#1E40AF` (color anterior que migramos)
|
||||
|
||||
6. Tests previos verdes: >= 455
|
||||
```
|
||||
|
||||
Si CUALQUIER validación falla, NO uses `present_files`. Reportá qué falló y esperá instrucciones.
|
||||
|
||||
### 4.3 Validación visual del humano
|
||||
|
||||
Después de las validaciones automatizadas, presentar los 3 PDFs vía `present_files`. El humano va a verificar visualmente:
|
||||
|
||||
1. ¿El header tiene logo "InQ ROI" en naranja en las 3 (o 4) páginas?
|
||||
2. ¿Hay un border-bottom naranja consistente en cada header?
|
||||
3. ¿El footer dice exactamente "InQ ROI · Powered by InQuality · [cliente] · [fecha]" sin versión?
|
||||
4. ¿La paginación "Página X de Y" es correcta en cada PDF?
|
||||
5. ¿Las tablas internas usan headers naranjas en lugar de azules?
|
||||
6. ¿El contenido de las páginas más allá del header/footer NO cambió respecto a la versión anterior?
|
||||
|
||||
---
|
||||
|
||||
## 5. Entregables (Definition of Done)
|
||||
|
||||
```
|
||||
[ ] src/lib/export/pdf-sections-shared.ts creado con drawHeader,
|
||||
drawFooter y PDF_COLORS exportados
|
||||
[ ] pdf-export.ts refactorizado para usar el módulo compartido
|
||||
[ ] pdf-sections.ts refactorizado para usar el módulo compartido
|
||||
[ ] pdf-sections-roi.ts refactorizado para usar el módulo compartido
|
||||
[ ] Cero ocurrencias de PDF.blue en grep
|
||||
[ ] Versión "v0.1.0" eliminada del footer de PDFs (mantener en web)
|
||||
[ ] 3 PDFs generados en tests/e2e/__output__/:
|
||||
- etapa-4-actual.pdf (3 páginas)
|
||||
- etapa-4-automatizado.pdf (3 páginas)
|
||||
- etapa-4-roi.pdf (4 páginas)
|
||||
[ ] 6 validaciones automatizadas confirmadas (palabras clave, prohibidos,
|
||||
páginas, tamaño, píxeles naranja, tests previos)
|
||||
[ ] PDFs presentados con present_files
|
||||
[ ] 455 tests previos + nuevos tests del módulo compartido = ~460+ verdes
|
||||
[ ] Tests nuevos para drawHeader y drawFooter (3-5 casos mínimo)
|
||||
[ ] npm run build limpio (sin warnings TS nuevos)
|
||||
[ ] Decisiones tomadas por iniciativa documentadas en reporte (Patrón C.6)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Tests obligatorios
|
||||
|
||||
### 6.1 Tests unitarios del módulo compartido
|
||||
|
||||
Crear `tests/lib/export/pdf-sections-shared.test.ts`:
|
||||
|
||||
```typescript
|
||||
describe('PDF shared sections', () => {
|
||||
describe('PDF_COLORS', () => {
|
||||
it('expone inqOrange como array RGB válido', () => {
|
||||
expect(PDF_COLORS.inqOrange).toEqual([245, 152, 69]);
|
||||
});
|
||||
|
||||
it('NO incluye colores de CONCILIA', () => {
|
||||
const allColors = JSON.stringify(PDF_COLORS);
|
||||
expect(allColors).not.toContain('87, 47, 162'); // #572FA2
|
||||
expect(allColors).not.toContain('203, 24, 137'); // #CB1889
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawHeader', () => {
|
||||
it('dibuja border-bottom 3px en página 1', () => {
|
||||
// mock jsPDF, capturar llamadas, assertions
|
||||
});
|
||||
|
||||
it('dibuja border-bottom 2px en página 2+', () => {
|
||||
// ...
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawFooter', () => {
|
||||
it('incluye "InQ ROI" y "Powered by InQuality"', () => {
|
||||
// ...
|
||||
});
|
||||
|
||||
it('NO incluye versión del producto', () => {
|
||||
// ...
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 6.2 Tests E2E
|
||||
|
||||
Adaptar tests E2E existentes para verificar que los 3 PDFs siguen generándose correctamente después del refactor.
|
||||
|
||||
---
|
||||
|
||||
## 7. Reporte final a entregar
|
||||
|
||||
Cuando termines, devolveme:
|
||||
|
||||
1. **Tabla de archivos modificados/creados** con líneas
|
||||
2. **Resultado de las validaciones automatizadas** (los 6 puntos del 4.2)
|
||||
3. **Cantidad final de tests verdes** (esperado 460+)
|
||||
4. **3 PDFs presentados con `present_files`** para validación visual mía
|
||||
5. **Hallazgos no esperados** — cualquier cosa que merezca decisión arquitectónica
|
||||
6. **Decisiones tomadas por iniciativa** — si aplicaste algún cambio Nivel 2 según política de CLAUDE.md, listalo acá. Si encontrás algo Nivel 3, **NO LO HAGAS** y reportalo como propuesta
|
||||
|
||||
NO arranques Etapa 5 sin mi OK explícito.
|
||||
|
||||
---
|
||||
|
||||
## 8. Reglas operativas (recordatorios)
|
||||
|
||||
1. **No tocar contenido de las páginas más allá del header/footer**. El rediseño de páginas viene en Etapas 5-7.
|
||||
2. **No cambiar la orientación de ninguna página**. La página BPMN sigue vertical en esta etapa (cambia en Etapa 5).
|
||||
3. **No agregar features nuevas al PDF**. Esta etapa es refactor + identidad, no nueva funcionalidad.
|
||||
4. **No racionalizar problemas**. Si una validación automatizada falla, investigar causa raíz.
|
||||
5. **Política de iniciativa proactiva (CLAUDE.md):**
|
||||
- Cambios Nivel 1 (refactor interno, mejor naming): ejecutar
|
||||
- Cambios Nivel 2 (visible pero reversible): ejecutar y reportar
|
||||
- Cambios Nivel 3 (marca, arquitectura, datos): CONSULTAR antes
|
||||
6. **Si encontrás contradicción** entre este prompt y la realidad del código (un archivo no está donde decía, un import no resuelve, etc.): **PARÁ y consultá**, no asumas.
|
||||
|
||||
---
|
||||
|
||||
## 9. Notas sobre dependencia con Etapa 5
|
||||
|
||||
Etapa 5 va a cambiar la orientación de la página BPMN a landscape. El módulo `pdf-sections-shared.ts` que crees en esta etapa debe estar preparado para esto:
|
||||
|
||||
- `drawHeader` y `drawFooter` deben recibir el ancho/alto disponible como parámetros implícitos del `doc` (jsPDF lo provee con `doc.internal.pageSize.getWidth()` y `getHeight()`)
|
||||
- NO hardcodear `pageWidth = 595` (A4 portrait). Si el header se llama desde una página landscape, debe adaptarse al ancho real
|
||||
- Probá esto con un test que llame `drawHeader` en una página landscape mock
|
||||
|
||||
Esto preserva el contrato del módulo para que Etapa 5 no requiera refactor adicional.
|
||||
@@ -1,13 +1,14 @@
|
||||
import { buildFileName } from './slug'
|
||||
import { calculateRoi } from '@/domain/roi'
|
||||
import {
|
||||
drawHeader, drawKpiCards, drawHeatmapImage, drawHeatmapLegend,
|
||||
drawAnalysisSection, drawMethodologySection, addPageNumbers, PDF,
|
||||
drawKpiCards, drawHeatmapImage, drawHeatmapLegend,
|
||||
drawAnalysisSection, drawMethodologySection, PDF,
|
||||
} from './pdf-sections'
|
||||
import {
|
||||
drawRoiExecutiveSummary, drawRoiKpiCards, buildComparisonTableConfig,
|
||||
drawRoiAnalysisPage, drawRoiMethodologySection,
|
||||
} from './pdf-sections-roi'
|
||||
import { drawSharedHeader, applyFootersToAllPages, PDF_COLORS } from './pdf-sections-shared'
|
||||
import type { Process, Simulation, Resource, Activity } from '@/domain/types'
|
||||
|
||||
export type ReportTab = 'actual' | 'automatizado' | 'roi'
|
||||
@@ -69,7 +70,10 @@ async function exportScenarioPdf(
|
||||
keywords: 'bpmn, costos, simulación',
|
||||
})
|
||||
|
||||
let y = drawHeader(docAny, process, simulation.executedAt)
|
||||
const headerOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt }
|
||||
|
||||
// Página 1
|
||||
let y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 1 })
|
||||
y = drawKpiCards(docAny, result, currency, y)
|
||||
y += 4
|
||||
|
||||
@@ -83,8 +87,9 @@ async function exportScenarioPdf(
|
||||
y = drawHeatmapLegend(docAny, y)
|
||||
y += 4
|
||||
|
||||
// Página 2 — análisis + tabla de costos
|
||||
docAny.addPage()
|
||||
y = PDF.margin
|
||||
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 2 })
|
||||
y = drawAnalysisSection(docAny, result, currency, y)
|
||||
y += 6
|
||||
|
||||
@@ -117,14 +122,16 @@ async function exportScenarioPdf(
|
||||
startY: y,
|
||||
margin: { left: PDF.margin, right: PDF.margin },
|
||||
styles: { fontSize: 8, cellPadding: 2.5, overflow: 'ellipsize' },
|
||||
headStyles: { fillColor: PDF.blue, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 8 },
|
||||
headStyles: { fillColor: PDF_COLORS.inqOrange, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 8 },
|
||||
alternateRowStyles: { fillColor: PDF.slate50 },
|
||||
columnStyles: { 0: { cellWidth: 48 } },
|
||||
})
|
||||
|
||||
// Página 3 — nota metodológica
|
||||
docAny.addPage()
|
||||
drawMethodologySection(docAny, process, PDF.margin)
|
||||
addPageNumbers(docAny, 'InQ ROI · Powered by InQuality', simulation.executedAt)
|
||||
y = drawSharedHeader(docAny, { ...headerOpts, pageNumber: 3 })
|
||||
drawMethodologySection(docAny, process, y)
|
||||
applyFootersToAllPages(docAny, { clientName: process.clientName || undefined, simulatedAt: simulation.executedAt })
|
||||
}
|
||||
|
||||
// ─── ROI — 4 páginas ──────────────────────────────────────────────────────────
|
||||
@@ -157,14 +164,16 @@ async function exportRoiPdf(
|
||||
keywords: 'bpmn, roi, automatización, payback',
|
||||
})
|
||||
|
||||
const roiHeaderOpts = { processName: process.name, clientName: process.clientName || undefined, simulatedAt: simulation.executedAt }
|
||||
|
||||
// ── Página 1: Header + resumen ejecutivo + 5 KPI cards ─────────────────────
|
||||
let y = drawHeader(doc, process, simulation.executedAt)
|
||||
let y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 1 })
|
||||
y = drawRoiExecutiveSummary(doc, process, roi, currency, y)
|
||||
y = drawRoiKpiCards(doc, roi, currency, process.analysisHorizonYears, y)
|
||||
|
||||
// ── Página 2: Tabla comparativa ────────────────────────────────────────────
|
||||
doc.addPage()
|
||||
y = PDF.margin
|
||||
y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 2 })
|
||||
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(12)
|
||||
@@ -189,14 +198,15 @@ async function exportRoiPdf(
|
||||
|
||||
// ── Página 3: Análisis textual + transparencia ─────────────────────────────
|
||||
doc.addPage()
|
||||
y = PDF.margin
|
||||
y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 3 })
|
||||
drawRoiAnalysisPage(doc, roi, process, result.perActivity, resultAutomated.perActivity, activities, currency, y)
|
||||
|
||||
// ── Página 4: Metodología ──────────────────────────────────────────────────
|
||||
doc.addPage()
|
||||
drawRoiMethodologySection(doc, process, PDF.margin)
|
||||
y = drawSharedHeader(doc, { ...roiHeaderOpts, pageNumber: 4 })
|
||||
drawRoiMethodologySection(doc, process, y)
|
||||
|
||||
addPageNumbers(doc, 'InQ ROI · Powered by InQuality', simulation.executedAt)
|
||||
applyFootersToAllPages(doc, { clientName: process.clientName || undefined, simulatedAt: simulation.executedAt })
|
||||
}
|
||||
|
||||
function formatNum(n: number): string {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { formatCurrency, formatPayback, formatPercent } from '@/lib/format'
|
||||
import type { ActivitySimResult, Process, Activity } from '@/domain/types'
|
||||
import type { RoiResult } from '@/domain/roi'
|
||||
import { PDF, type DocLike } from './pdf-sections'
|
||||
import { PDF_COLORS } from './pdf-sections-shared'
|
||||
|
||||
const ROI_HIGH_THRESHOLD = 1000
|
||||
|
||||
@@ -194,7 +195,7 @@ export function buildComparisonTableConfig(
|
||||
startY,
|
||||
margin: { left: PDF.margin, right: PDF.margin },
|
||||
styles: { fontSize: 7.5, cellPadding: 2, overflow: 'ellipsize' },
|
||||
headStyles: { fillColor: PDF.blue, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 7.5 },
|
||||
headStyles: { fillColor: PDF_COLORS.inqOrange, textColor: [255, 255, 255], fontStyle: 'bold', fontSize: 7.5 },
|
||||
columnStyles: { 0: { cellWidth: 42 } },
|
||||
}
|
||||
}
|
||||
|
||||
191
src/lib/export/pdf-sections-shared.ts
Normal file
191
src/lib/export/pdf-sections-shared.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Módulo compartido de header/footer PDF — Sprint 1.5 Etapa 4.
|
||||
*
|
||||
* Provee drawSharedHeader, drawSharedFooter, applyFootersToAllPages
|
||||
* y PDF_COLORS para uso en los 3 PDFs (Actual, Automatizado, ROI).
|
||||
*
|
||||
* Diseñado para ser compatible con páginas landscape (Etapa 5):
|
||||
* usa doc.internal.pageSize.getWidth/Height() en lugar de constantes A4.
|
||||
*/
|
||||
import { formatSimulationTimestamp } from '@/lib/format'
|
||||
|
||||
// ─── Tipo DocLike ─────────────────────────────────────────────────────────────
|
||||
// Superset del DocLike anterior: agrega pageSize para compatibilidad landscape.
|
||||
// pdf-sections.ts re-exporta este tipo para mantener compatibilidad de imports.
|
||||
|
||||
export type DocLike = {
|
||||
setFont: (name: string, style: string) => void
|
||||
setFontSize: (size: number) => void
|
||||
setTextColor: (...args: number[]) => void
|
||||
setFillColor: (...args: number[]) => void
|
||||
setDrawColor: (...args: number[]) => void
|
||||
setLineWidth: (w: number) => void
|
||||
text: (text: string | string[], x: number, y: number, opts?: Record<string, unknown>) => void
|
||||
rect: (x: number, y: number, w: number, h: number, style?: string) => void
|
||||
roundedRect: (x: number, y: number, w: number, h: number, rx: number, ry: number, style?: string) => void
|
||||
line: (x1: number, y1: number, x2: number, y2: number) => void
|
||||
addImage: (img: string, format: string, x: number, y: number, w: number, h: number) => void
|
||||
addPage: () => void
|
||||
setPage: (page: number) => void
|
||||
internal: {
|
||||
getNumberOfPages: () => number
|
||||
pageSize: {
|
||||
getWidth: () => number
|
||||
getHeight: () => number
|
||||
}
|
||||
}
|
||||
splitTextToSize: (text: string, maxWidth: number) => string[]
|
||||
}
|
||||
|
||||
// ─── Paleta PDF ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const PDF_COLORS = {
|
||||
inqOrange: [245, 152, 69] as [number, number, number], // #F59845
|
||||
inqOrangeDark: [180, 83, 9] as [number, number, number], // #B45309
|
||||
inqMagenta: [237, 62, 143] as [number, number, number], // #ED3E8F
|
||||
textPrimary: [15, 23, 42] as [number, number, number], // #0F172A slate-900
|
||||
textSecondary: [71, 85, 105] as [number, number, number], // #475569 slate-600
|
||||
textTertiary: [100, 116, 139] as [number, number, number], // #64748B slate-500
|
||||
textDisabled: [148, 163, 184] as [number, number, number], // #94A3B8 slate-400
|
||||
borderLight: [226, 232, 240] as [number, number, number], // #E2E8F0 slate-200
|
||||
slate50: [248, 250, 252] as [number, number, number], // #F8FAFC
|
||||
successGreen: [22, 163, 74] as [number, number, number], // #16A34A
|
||||
dangerRed: [239, 68, 68] as [number, number, number], // #EF4444
|
||||
warningAmber: [245, 158, 11] as [number, number, number], // #F59E0B
|
||||
} as const
|
||||
|
||||
const MARGIN = 20 // mm
|
||||
|
||||
// ─── Header compartido ────────────────────────────────────────────────────────
|
||||
|
||||
export interface PdfHeaderOptions {
|
||||
pageNumber: number // 1-based — determina grosor del border (3px pág 1, 2px resto)
|
||||
processName: string
|
||||
sectionTitle?: string // subtítulo de sección, ej. "Análisis de retorno de inversión"
|
||||
clientName?: string
|
||||
simulatedAt: number // Unix timestamp ms
|
||||
}
|
||||
|
||||
/**
|
||||
* Dibuja el header InQ-branded.
|
||||
* - Logo "InQ ROI" naranja a la izquierda, fecha a la derecha
|
||||
* - Nombre del proceso + cliente
|
||||
* - Border-bottom naranja (más grueso en página 1)
|
||||
* Retorna la coordenada y donde empieza el contenido.
|
||||
*/
|
||||
export function drawSharedHeader(doc: DocLike, options: PdfHeaderOptions): number {
|
||||
const pageW = doc.internal.pageSize.getWidth()
|
||||
const contentRight = pageW - MARGIN
|
||||
let y = MARGIN
|
||||
|
||||
// Logo "InQ ROI" — naranja bold
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(13)
|
||||
doc.setTextColor(...PDF_COLORS.inqOrange)
|
||||
doc.text('InQ ROI', MARGIN, y)
|
||||
|
||||
// Fecha — derecha, slate-400
|
||||
const { date } = formatSimulationTimestamp(options.simulatedAt)
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(7.5)
|
||||
doc.setTextColor(...PDF_COLORS.textDisabled)
|
||||
doc.text(date, contentRight, y, { align: 'right' } as Record<string, unknown>)
|
||||
y += 7
|
||||
|
||||
// Nombre del proceso — bold slate-900
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(11)
|
||||
doc.setTextColor(...PDF_COLORS.textPrimary)
|
||||
doc.text(options.processName, MARGIN, y)
|
||||
|
||||
// Cliente — derecha, slate-500
|
||||
if (options.clientName) {
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(7.5)
|
||||
doc.setTextColor(...PDF_COLORS.textTertiary)
|
||||
doc.text(options.clientName, contentRight, y, { align: 'right' } as Record<string, unknown>)
|
||||
}
|
||||
y += 5
|
||||
|
||||
// Subtítulo de sección (opcional)
|
||||
if (options.sectionTitle) {
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(8.5)
|
||||
doc.setTextColor(...PDF_COLORS.textSecondary)
|
||||
doc.text(options.sectionTitle, MARGIN, y)
|
||||
y += 4.5
|
||||
}
|
||||
|
||||
// Border-bottom naranja InQ: 0.8mm (≈3pt) en página 1, 0.5mm (≈2pt) en el resto
|
||||
const borderWidth = options.pageNumber === 1 ? 0.8 : 0.5
|
||||
doc.setDrawColor(...PDF_COLORS.inqOrange)
|
||||
doc.setLineWidth(borderWidth)
|
||||
doc.line(MARGIN, y, contentRight, y)
|
||||
y += 5
|
||||
|
||||
// Reset lineWidth para no afectar el contenido siguiente
|
||||
doc.setLineWidth(0.2)
|
||||
|
||||
return y
|
||||
}
|
||||
|
||||
// ─── Footer compartido ────────────────────────────────────────────────────────
|
||||
|
||||
export interface PdfFooterOptions {
|
||||
clientName?: string
|
||||
simulatedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Dibuja el footer en la posición inferior fija de la página actual.
|
||||
* - Border-top slate-200
|
||||
* - Izquierda: "InQ ROI · Powered by InQuality · [cliente] · [fecha]"
|
||||
* - Derecha: "Página X de Y"
|
||||
* No incluye versión del producto.
|
||||
*/
|
||||
export function drawSharedFooter(
|
||||
doc: DocLike,
|
||||
options: PdfFooterOptions & { pageNumber: number; totalPages: number }
|
||||
): void {
|
||||
const pageW = doc.internal.pageSize.getWidth()
|
||||
const pageH = doc.internal.pageSize.getHeight()
|
||||
const contentRight = pageW - MARGIN
|
||||
const footerLineY = pageH - 14
|
||||
const footerTextY = pageH - 9
|
||||
|
||||
// Border-top
|
||||
doc.setDrawColor(...PDF_COLORS.borderLight)
|
||||
doc.setLineWidth(0.2)
|
||||
doc.line(MARGIN, footerLineY, contentRight, footerLineY)
|
||||
|
||||
// Texto izquierda
|
||||
const { date } = formatSimulationTimestamp(options.simulatedAt)
|
||||
const parts: string[] = ['InQ ROI', 'Powered by InQuality']
|
||||
if (options.clientName) parts.push(options.clientName)
|
||||
parts.push(date)
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(7)
|
||||
doc.setTextColor(...PDF_COLORS.textDisabled)
|
||||
doc.text(parts.join(' · '), MARGIN, footerTextY)
|
||||
|
||||
// Paginación derecha
|
||||
doc.text(
|
||||
`Página ${options.pageNumber} de ${options.totalPages}`,
|
||||
contentRight,
|
||||
footerTextY,
|
||||
{ align: 'right' } as Record<string, unknown>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica footer a TODAS las páginas del documento.
|
||||
* Llamar después de agregar todo el contenido.
|
||||
*/
|
||||
export function applyFootersToAllPages(doc: DocLike, options: PdfFooterOptions): void {
|
||||
const total = doc.internal.getNumberOfPages()
|
||||
for (let i = 1; i <= total; i++) {
|
||||
doc.setPage(i)
|
||||
drawSharedFooter(doc, { ...options, pageNumber: i, totalPages: total })
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import { formatCurrency, formatMinutes, formatSimulationTimestamp } from '@/lib/format'
|
||||
import type { SimulationResult, Process } from '@/domain/types'
|
||||
import { PDF_COLORS, type DocLike } from './pdf-sections-shared'
|
||||
|
||||
// Re-exportar DocLike para compatibilidad de imports existentes (pdf-sections-roi, tests)
|
||||
export type { DocLike } from './pdf-sections-shared'
|
||||
|
||||
// Unidades: mm. A4 = 210 × 297mm, márgenes 20mm, área útil 170 × 257mm
|
||||
// PDF.blue eliminado en Etapa 4 — usar PDF_COLORS.inqOrange del módulo compartido
|
||||
export const PDF = {
|
||||
pageW: 210, pageH: 297,
|
||||
margin: 20,
|
||||
contentW: 170,
|
||||
blue: [30, 64, 175] as [number, number, number],
|
||||
inqOrange: PDF_COLORS.inqOrange,
|
||||
slate900: [15, 23, 42] as [number, number, number],
|
||||
slate700: [51, 65, 85] as [number, number, number],
|
||||
slate500: [100, 116, 139] as [number, number, number],
|
||||
@@ -17,25 +22,6 @@ export const PDF = {
|
||||
heatHigh: [239, 68, 68] as [number, number, number],
|
||||
}
|
||||
|
||||
// Tipo mínimo de doc para los helpers (evitar importar jsPDF en este archivo)
|
||||
export type DocLike = {
|
||||
setFont: (name: string, style: string) => void
|
||||
setFontSize: (size: number) => void
|
||||
setTextColor: (...args: number[]) => void
|
||||
setFillColor: (...args: number[]) => void
|
||||
setDrawColor: (...args: number[]) => void
|
||||
setLineWidth: (w: number) => void
|
||||
text: (text: string | string[], x: number, y: number, opts?: Record<string, unknown>) => void
|
||||
rect: (x: number, y: number, w: number, h: number, style?: string) => void
|
||||
roundedRect: (x: number, y: number, w: number, h: number, rx: number, ry: number, style?: string) => void
|
||||
line: (x1: number, y1: number, x2: number, y2: number) => void
|
||||
addImage: (img: string, format: string, x: number, y: number, w: number, h: number) => void
|
||||
addPage: () => void
|
||||
setPage: (page: number) => void
|
||||
internal: { getNumberOfPages: () => number }
|
||||
splitTextToSize: (text: string, maxWidth: number) => string[]
|
||||
}
|
||||
|
||||
export function drawHeader(doc: DocLike, process: Process, simulatedAt: number): number {
|
||||
let y = PDF.margin
|
||||
|
||||
@@ -109,7 +95,7 @@ export function drawKpiCards(
|
||||
// Valor grande
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(card.value.length > 16 ? 9 : 12)
|
||||
doc.setTextColor(card.accent ? PDF.blue[0] : PDF.slate900[0], card.accent ? PDF.blue[1] : PDF.slate900[1], card.accent ? PDF.blue[2] : PDF.slate900[2])
|
||||
doc.setTextColor(...(card.accent ? PDF.inqOrange : PDF.slate900))
|
||||
doc.text(card.value, x + 4, y + 16)
|
||||
|
||||
maxY = Math.max(maxY, y + cardH)
|
||||
|
||||
@@ -24,7 +24,13 @@ const mockDoc = {
|
||||
setPage: vi.fn(),
|
||||
save: vi.fn(),
|
||||
splitTextToSize: vi.fn().mockImplementation((text: string) => [text]),
|
||||
internal: { getNumberOfPages: vi.fn().mockReturnValue(4) },
|
||||
internal: {
|
||||
getNumberOfPages: vi.fn().mockReturnValue(4),
|
||||
pageSize: {
|
||||
getWidth: vi.fn().mockReturnValue(210),
|
||||
getHeight: vi.fn().mockReturnValue(297),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('jspdf', () => {
|
||||
|
||||
@@ -23,7 +23,13 @@ const mockDoc = {
|
||||
setPage: vi.fn(),
|
||||
save: vi.fn(),
|
||||
splitTextToSize: vi.fn().mockImplementation((text: string) => [text]),
|
||||
internal: { getNumberOfPages: vi.fn().mockReturnValue(3) },
|
||||
internal: {
|
||||
getNumberOfPages: vi.fn().mockReturnValue(3),
|
||||
pageSize: {
|
||||
getWidth: vi.fn().mockReturnValue(210),
|
||||
getHeight: vi.fn().mockReturnValue(297),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Arrow functions no pueden ser constructoras — usar function() regular
|
||||
|
||||
148
tests/lib/export/pdf-sections-shared.test.ts
Normal file
148
tests/lib/export/pdf-sections-shared.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Tests del módulo compartido de header/footer PDF — Etapa 4 Sprint 1.5.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { PDF_COLORS, drawSharedHeader, drawSharedFooter, applyFootersToAllPages } from '@/lib/export/pdf-sections-shared'
|
||||
|
||||
// ─── Mock DocLike ─────────────────────────────────────────────────────────────
|
||||
|
||||
function makeMockDoc(pageW = 210, pageH = 297, totalPages = 2) {
|
||||
return {
|
||||
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(),
|
||||
splitTextToSize: vi.fn().mockImplementation((t: string) => [t]),
|
||||
internal: {
|
||||
getNumberOfPages: vi.fn().mockReturnValue(totalPages),
|
||||
pageSize: {
|
||||
getWidth: vi.fn().mockReturnValue(pageW),
|
||||
getHeight: vi.fn().mockReturnValue(pageH),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const BASE_OPTS = { processName: 'Proceso de prueba', simulatedAt: new Date('2026-05-17T10:00:00Z').getTime() }
|
||||
|
||||
// ─── PDF_COLORS ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PDF_COLORS', () => {
|
||||
it('expone inqOrange como array RGB válido [245, 152, 69]', () => {
|
||||
expect(PDF_COLORS.inqOrange).toEqual([245, 152, 69])
|
||||
})
|
||||
|
||||
it('NO incluye colores de CONCILIA (#572FA2 → [87, 47, 162])', () => {
|
||||
const all = JSON.stringify(PDF_COLORS)
|
||||
expect(all).not.toContain('87, 47, 162') // #572FA2 morado CONCILIA
|
||||
expect(all).not.toContain('203, 24, 137') // #CB1889 magenta CONCILIA
|
||||
})
|
||||
|
||||
it('tiene al menos 8 colores definidos', () => {
|
||||
expect(Object.keys(PDF_COLORS).length).toBeGreaterThanOrEqual(8)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── drawSharedHeader ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('drawSharedHeader', () => {
|
||||
let doc: ReturnType<typeof makeMockDoc>
|
||||
|
||||
beforeEach(() => { doc = makeMockDoc() })
|
||||
|
||||
it('retorna un número mayor que el margen (20mm) — espacio del header', () => {
|
||||
const y = drawSharedHeader(doc, { ...BASE_OPTS, pageNumber: 1 })
|
||||
expect(y).toBeGreaterThan(20)
|
||||
})
|
||||
|
||||
it('dibuja border-bottom más grueso en página 1 que en páginas siguientes', () => {
|
||||
drawSharedHeader(doc, { ...BASE_OPTS, pageNumber: 1 })
|
||||
const page1LineWidths = doc.setLineWidth.mock.calls.map((c: number[]) => c[0])
|
||||
|
||||
doc.setLineWidth.mockClear()
|
||||
drawSharedHeader(doc, { ...BASE_OPTS, pageNumber: 2 })
|
||||
const page2LineWidths = doc.setLineWidth.mock.calls.map((c: number[]) => c[0])
|
||||
|
||||
// El grosor máximo de página 1 debe ser mayor que el de página 2
|
||||
expect(Math.max(...page1LineWidths)).toBeGreaterThan(Math.max(...page2LineWidths))
|
||||
})
|
||||
|
||||
it('dibuja el texto "InQ ROI" en la primera llamada a text()', () => {
|
||||
drawSharedHeader(doc, { ...BASE_OPTS, pageNumber: 1 })
|
||||
const firstTextCall = doc.text.mock.calls[0]
|
||||
expect(firstTextCall[0]).toBe('InQ ROI')
|
||||
})
|
||||
|
||||
it('usa inqOrange para el texto del logo', () => {
|
||||
drawSharedHeader(doc, { ...BASE_OPTS, pageNumber: 1 })
|
||||
// La primera llamada a setTextColor debe ser el naranja InQ
|
||||
const firstColor = doc.setTextColor.mock.calls[0]
|
||||
expect(firstColor).toEqual(PDF_COLORS.inqOrange)
|
||||
})
|
||||
|
||||
it('usa doc.internal.pageSize.getWidth() — compatible con landscape', () => {
|
||||
const landscapeDoc = makeMockDoc(297, 210)
|
||||
const y = drawSharedHeader(landscapeDoc, { ...BASE_OPTS, pageNumber: 1 })
|
||||
expect(landscapeDoc.internal.pageSize.getWidth).toHaveBeenCalled()
|
||||
expect(y).toBeGreaterThan(20)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── drawSharedFooter ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('drawSharedFooter', () => {
|
||||
let doc: ReturnType<typeof makeMockDoc>
|
||||
|
||||
beforeEach(() => { doc = makeMockDoc() })
|
||||
|
||||
it('incluye "InQ ROI" en el texto del footer', () => {
|
||||
drawSharedFooter(doc, { ...BASE_OPTS, pageNumber: 1, totalPages: 3 })
|
||||
const textCalls = doc.text.mock.calls.map((c: unknown[]) => c[0])
|
||||
expect(textCalls.some((t: unknown) => typeof t === 'string' && t.includes('InQ ROI'))).toBe(true)
|
||||
})
|
||||
|
||||
it('incluye "Powered by InQuality" en el texto del footer', () => {
|
||||
drawSharedFooter(doc, { ...BASE_OPTS, pageNumber: 1, totalPages: 3 })
|
||||
const textCalls = doc.text.mock.calls.map((c: unknown[]) => c[0])
|
||||
expect(textCalls.some((t: unknown) => typeof t === 'string' && t.includes('Powered by InQuality'))).toBe(true)
|
||||
})
|
||||
|
||||
it('muestra paginación correcta "Página X de Y"', () => {
|
||||
drawSharedFooter(doc, { ...BASE_OPTS, pageNumber: 2, totalPages: 4 })
|
||||
const textCalls = doc.text.mock.calls.map((c: unknown[]) => c[0])
|
||||
expect(textCalls.some((t: unknown) => typeof t === 'string' && t === 'Página 2 de 4')).toBe(true)
|
||||
})
|
||||
|
||||
it('NO incluye versión del producto (v0.1.0)', () => {
|
||||
drawSharedFooter(doc, { ...BASE_OPTS, pageNumber: 1, totalPages: 3 })
|
||||
const allText = doc.text.mock.calls.map((c: unknown[]) => c[0]).join(' ')
|
||||
expect(allText).not.toContain('v0.')
|
||||
expect(allText).not.toContain('v0.1')
|
||||
})
|
||||
|
||||
it('usa doc.internal.pageSize.getHeight() para posición inferior', () => {
|
||||
drawSharedFooter(doc, { ...BASE_OPTS, pageNumber: 1, totalPages: 3 })
|
||||
expect(doc.internal.pageSize.getHeight).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── applyFootersToAllPages ───────────────────────────────────────────────────
|
||||
|
||||
describe('applyFootersToAllPages', () => {
|
||||
it('llama setPage para cada página del documento', () => {
|
||||
const doc = makeMockDoc(210, 297, 4)
|
||||
applyFootersToAllPages(doc, BASE_OPTS)
|
||||
expect(doc.setPage).toHaveBeenCalledTimes(4)
|
||||
expect(doc.setPage).toHaveBeenNthCalledWith(1, 1)
|
||||
expect(doc.setPage).toHaveBeenNthCalledWith(4, 4)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user