Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Tier 1 — Automatizado Build: ✅ limpio Lint: ✅ limpio en archivos nuevos de Sprint 4 (separé resource-types.ts de resource-display.tsx para sacar 2 warnings de Fast Refresh; quedan 0 errores/warnings nuevos). Quedó 1 error preexistente en ReportPage.tsx (no tocado desde Sprint 3, fuera de scope) y ~102 warnings no-explicit-any preexistentes en tests viejos. Tests unitarios: ✅ 521/521 verdes Tests E2E: ⚠️ 43-44/48 verdes (varía levemente entre corridas) — detalle abajo Auditoría inicial vs. estado final Antes: 39/39 specs (100%) fallaban — ROJO-AUTH puro, todos por #bpmn-file-input nunca apareciendo (esperaban el flujo pre-Sprint-4 directamente en /). Decisión tomada (con tu input): Opción A — sesión real. Creaste el usuario martin.bernal@inquality.com.py; yo verifiqué que no tenía fila en public.users y se la inserté con platform_role='platform_admin'. Implementé tests/e2e/setup/auth-setup.ts como globalSetup de Playwright: hace signInWithPassword, inyecta la sesión real en localStorage con el formato exacto que usa supabase-js v2, y genera tests/e2e/setup/auth-state.json (gitignored) en cada corrida. Fix adicional necesario: todos los specs navegaban a / esperando el input de archivo ahí — ahora vive en /import. Corregí los 18 page.goto('/') → /import, incluyendo reescribir el flujo obsoleto de prod-smoke.spec.ts (esperaba una landing pre-auth que ya no existe). Resultado: 33→43-44 specs pasan. Quedan 5-6 ROJO-OTRO, con dos causas raíz identificadas (no relacionadas con auth, fuera del mandato de esta etapa que pide no reescribir lógica de tests): Botón "Guardar" en ActivityPanel queda deshabilitado (3 specs de PDF) — encontré algo extraño: el texto del botón dice "Guardar cambios" (que solo se renderiza si isDirty=true) pero Playwright lo sigue viendo disabled. Esto contradice la lógica simple de disabled={!isDirty} en el componente — sospecho un bug real de re-render en producción, pero diagnosticarlo a fondo requiere debugging en vivo. Recomiendo abrir esto como bug dedicado en un sprint futuro. 2 specs usan IndexedDB/Dexie directamente (export-pdf.spec.ts, validate-dod-etapa4.spec.ts) — Dexie se eliminó completamente en Etapa 4. Estos tests necesitan reescritura para inyectar datos vía Supabase en vez de IndexedDB — eso sí es "reescribir lógica de tests", explícitamente fuera de este alcance. Los resultados varían levemente entre corridas porque Playwright corre specs en paralelo bajo la misma cuenta de test compartida — puede haber interferencia de datos entre specs concurrentes. Sugiero como mejora futura (no implementada ahora): --workers=1 para diagnóstico determinístico. Entregables nuevos tests/e2e/setup/auth-setup.ts — global setup con sesión real tests/e2e/sprint-4-smoke.spec.ts — 9 tests (4 sin auth: login, redirects de /, /workspace, /recursos; 5 con auth: biblioteca sin 500, sin 406 en simulations, catálogo carga, avatar visible, import navega a workspace) docs/CHECKLIST_ENTREGA.md — las 4 tiers completas .env.test (gitignored) + .env.example documentado .gitignore actualizado (.env.test, auth-state.json) Polish Textos de OwnershipBadge y "Compartir con equipo" ya estaban en español — sin cambios. Tooltip "Disponible próximamente" para "Compartir con equipo": no lo agregué, tal como pedía el propio brief, consultarte antes. ¿Lo querés?
421 lines
19 KiB
TypeScript
421 lines
19 KiB
TypeScript
/**
|
|
* E2E: Verifica que el export PDF y CSV funcionen de extremo a extremo.
|
|
*
|
|
* Por cada BPMN: importa → simula → descarga PDF y CSV → verifica tamaño y contenido.
|
|
* El test de acentos verifica que los caracteres UTF-8 se preserven en el PDF.
|
|
*
|
|
* Ejecutar: npx playwright test
|
|
*/
|
|
import { test, expect } from '@playwright/test'
|
|
import { readFileSync, mkdirSync } from 'fs'
|
|
import { resolve } from 'path'
|
|
import { fileURLToPath } from 'url'
|
|
import { PDFParse } from 'pdf-parse'
|
|
import sharp from 'sharp'
|
|
import { PDFDocument, PDFName, PDFRawStream } from 'pdf-lib'
|
|
|
|
test.use({ storageState: 'tests/e2e/setup/auth-state.json' })
|
|
|
|
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
const BPMN_DIR = resolve(__dirname, '../../public/sample-processes')
|
|
const OUTPUT_DIR = resolve(__dirname, '__output__')
|
|
|
|
test.beforeAll(() => {
|
|
mkdirSync(OUTPUT_DIR, { recursive: true })
|
|
})
|
|
|
|
// ─── Helper: parsea texto de un buffer PDF con pdf-parse v2 ──────────────────
|
|
|
|
async function parsePdf(buffer: Buffer): Promise<{ numpages: number; text: string }> {
|
|
const parser = new PDFParse({ data: buffer })
|
|
const result = await parser.getText()
|
|
await parser.destroy()
|
|
return { numpages: result.total, text: result.text }
|
|
}
|
|
|
|
// ─── Helper: importa un BPMN, simula y llega al reporte ──────────────────────
|
|
|
|
async function importAndSimulate(page: import('@playwright/test').Page, bpmnFile: string) {
|
|
await page.goto('/import')
|
|
|
|
// Subir el BPMN vía el input oculto (equivalente a drag-and-drop programático)
|
|
const fileInput = page.locator('#bpmn-file-input')
|
|
await fileInput.setInputFiles(resolve(BPMN_DIR, bpmnFile))
|
|
|
|
// Esperar navegación al workspace
|
|
await page.waitForURL(/\/workspace\//, { timeout: 15_000 })
|
|
|
|
// Esperar a que desaparezca el spinner de carga (workspace renderiza el botón Simular)
|
|
await page.waitForSelector('button:has-text("Simular")', { timeout: 15_000 })
|
|
|
|
// Tiempo adicional para que Dexie cargue gateways y actividades en la store
|
|
await page.waitForTimeout(1_500)
|
|
|
|
// Verificar que el botón no está deshabilitado y hacer clic
|
|
const simularBtn = page.getByRole('button', { name: 'Simular' })
|
|
await expect(simularBtn).not.toBeDisabled({ timeout: 5_000 })
|
|
await simularBtn.click()
|
|
|
|
// Esperar navegación al reporte
|
|
await page.waitForURL(/\/report\//, { timeout: 20_000 })
|
|
|
|
// Esperar a que el reporte cargue y el botón de export sea visible
|
|
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 30_000 })
|
|
// Esperar a que bpmn-js en HeatmapCanvas haya renderizado el diagrama.
|
|
// .djs-group es creado por bpmn-js después de importXML() — confirma render real.
|
|
await page.waitForSelector('.bpmn-container .djs-group', { timeout: 30_000 })
|
|
}
|
|
|
|
// ─── Casos por BPMN ──────────────────────────────────────────────────────────
|
|
|
|
const CASES = [
|
|
{
|
|
file: 'simple-linear.bpmn',
|
|
slug: 'simple-linear',
|
|
processNameFragment: 'simple-linear',
|
|
currency: 'USD',
|
|
},
|
|
{
|
|
file: 'medium-with-gateways.bpmn',
|
|
slug: 'medium-with-gateways',
|
|
processNameFragment: 'medium-with-gateways',
|
|
currency: 'USD',
|
|
},
|
|
{
|
|
file: 'complex-with-loop.bpmn',
|
|
slug: 'complex-with-loop',
|
|
processNameFragment: 'complex-with-loop',
|
|
currency: 'USD',
|
|
},
|
|
]
|
|
|
|
for (const c of CASES) {
|
|
test(`Export PDF + CSV — ${c.file}`, async ({ page }) => {
|
|
await importAndSimulate(page, c.file)
|
|
|
|
// ── PDF ─────────────────────────────────────────────────────────────────
|
|
const pdfPath = resolve(OUTPUT_DIR, `${c.slug}.pdf`)
|
|
|
|
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
|
|
await page.getByRole('button', { name: 'Exportar PDF' }).click()
|
|
const dl = await pdfDownload
|
|
await dl.saveAs(pdfPath)
|
|
|
|
// Esperar a que el estado "Generando…" desaparezca
|
|
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 30_000 })
|
|
|
|
// Verificar tamaño
|
|
const pdfBuffer = readFileSync(pdfPath)
|
|
const pdfBytes = pdfBuffer.length
|
|
console.log(`\n📄 ${c.file} — PDF: ${pdfBytes} bytes (${(pdfBytes / 1024).toFixed(1)} KB)`)
|
|
|
|
// PDF mínimo: debe tener al menos algo de contenido
|
|
expect(pdfBytes, `PDF de ${c.file} debe ser > 40 KB`).toBeGreaterThan(40_000)
|
|
expect(pdfBytes, `PDF de ${c.file} debe ser < 2 MB`).toBeLessThan(2_000_000)
|
|
|
|
// El heatmap JPEG debe estar embebido en el PDF (marker SOI: FF D8 FF).
|
|
// Sin actividades con costos reales el JPEG es pequeño (~32 KB, color neutro #cbd5e1)
|
|
// pero SIEMPRE debe estar presente cuando el canvas se renderizó correctamente.
|
|
const jpegMarker = Buffer.from([0xff, 0xd8, 0xff])
|
|
const hasJpeg = pdfBuffer.includes(jpegMarker)
|
|
console.log(` Heatmap JPEG embebido: ${hasJpeg}`)
|
|
expect(hasJpeg, 'PDF generado sin heatmap embebido — la race condition de captureImage no fue resuelta').toBe(true)
|
|
|
|
// Verificar contenido
|
|
const { numpages, text } = await parsePdf(pdfBuffer)
|
|
console.log(` Páginas: ${numpages}`)
|
|
console.log(` Texto (primeros 200 chars): ${text.slice(0, 200).replace(/\n/g, ' ')}`)
|
|
|
|
expect(numpages, 'PDF debe tener al menos 2 páginas').toBeGreaterThanOrEqual(2)
|
|
// El KPI de costo aparece en mayúsculas en el PDF: "COSTO TOTAL ESPERADO"
|
|
expect(text, 'PDF debe contener "COSTO TOTAL"').toContain('COSTO TOTAL')
|
|
expect(text, `PDF debe contener la moneda "${c.currency}"`).toContain(c.currency)
|
|
expect(text, 'PDF debe contener el nombre del proceso').toContain(c.processNameFragment)
|
|
|
|
// ── CSV ─────────────────────────────────────────────────────────────────
|
|
const csvPath = resolve(OUTPUT_DIR, `${c.slug}.csv`)
|
|
|
|
const csvDownload = page.waitForEvent('download')
|
|
await page.getByRole('button', { name: 'Exportar CSV' }).click()
|
|
const dlCsv = await csvDownload
|
|
await dlCsv.saveAs(csvPath)
|
|
|
|
const csvBuffer = readFileSync(csvPath)
|
|
const csvBytes = csvBuffer.length
|
|
console.log(` CSV: ${csvBytes} bytes`)
|
|
|
|
expect(csvBytes, `CSV de ${c.file} debe ser > 100 bytes`).toBeGreaterThan(100)
|
|
|
|
const csvText = csvBuffer.toString('utf-8')
|
|
expect(csvText, 'CSV debe tener BOM UTF-8').toMatch(/^/)
|
|
expect(csvText, 'CSV debe contener "Costo total"').toContain('Costo total')
|
|
expect(csvText, 'CSV debe contener la moneda').toContain(c.currency)
|
|
})
|
|
}
|
|
|
|
// ─── Test especial: acentos y ñ ───────────────────────────────────────────────
|
|
|
|
test('Export PDF — acentos y ñ se preservan en el PDF (accent-test.bpmn)', async ({ page }) => {
|
|
await importAndSimulate(page, 'accent-test.bpmn')
|
|
|
|
const pdfPath = resolve(OUTPUT_DIR, 'accent-test.pdf')
|
|
|
|
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
|
|
await page.getByRole('button', { name: 'Exportar PDF' }).click()
|
|
const dl = await pdfDownload
|
|
await dl.saveAs(pdfPath)
|
|
|
|
const pdfBuffer = readFileSync(pdfPath)
|
|
const pdfBytes = pdfBuffer.length
|
|
console.log(`\n🔤 accent-test.bpmn — PDF: ${pdfBytes} bytes (${(pdfBytes / 1024).toFixed(1)} KB)`)
|
|
|
|
expect(pdfBytes, 'PDF debe ser > 40 KB').toBeGreaterThan(40_000)
|
|
|
|
const jpegMarker = Buffer.from([0xff, 0xd8, 0xff])
|
|
const hasJpeg = pdfBuffer.includes(jpegMarker)
|
|
console.log(` Heatmap JPEG embebido: ${hasJpeg}`)
|
|
expect(hasJpeg, 'PDF generado sin heatmap embebido — la race condition de captureImage no fue resuelta').toBe(true)
|
|
|
|
const { numpages, text } = await parsePdf(pdfBuffer)
|
|
console.log(` Páginas: ${numpages}`)
|
|
console.log(` Texto completo:\n${text}`)
|
|
|
|
expect(numpages).toBeGreaterThanOrEqual(2)
|
|
|
|
// Verificar que los caracteres acentuados se preservan
|
|
expect(text, 'PDF debe contener "Análisis de procesos contables"')
|
|
.toContain('Análisis de procesos contables')
|
|
|
|
expect(text, 'PDF debe contener "Validación de información"')
|
|
.toContain('Validación de información')
|
|
|
|
expect(text, 'PDF debe contener "Inspección visual y funcional"')
|
|
.toContain('Inspección visual y funcional')
|
|
|
|
expect(text, 'PDF debe contener "Diseño de campañas"')
|
|
.toContain('Diseño de campañas')
|
|
|
|
// CSV del accent-test
|
|
await page.waitForSelector('button:has-text("Exportar CSV")', { timeout: 10_000 })
|
|
const csvPath = resolve(OUTPUT_DIR, 'accent-test.csv')
|
|
const csvDownload = page.waitForEvent('download')
|
|
await page.getByRole('button', { name: 'Exportar CSV' }).click()
|
|
const dlCsv = await csvDownload
|
|
await dlCsv.saveAs(csvPath)
|
|
|
|
const csvText = readFileSync(csvPath, 'utf-8')
|
|
console.log(` CSV (primeros 400 chars): ${csvText.slice(0, 400)}`)
|
|
|
|
expect(csvText).toContain('Análisis de procesos contables')
|
|
expect(csvText).toContain('Validación de información')
|
|
expect(csvText).toContain('Inspección visual y funcional')
|
|
expect(csvText).toContain('Diseño de campañas')
|
|
})
|
|
|
|
// ─── Helpers para extracción y análisis de JPEG ───────────────────────────────
|
|
|
|
/**
|
|
* Extrae el primer stream JPEG (DCTDecode) del PDF.
|
|
* Intenta con pdf-lib; si falla, usa búsqueda binaria de marcador SOI/EOI.
|
|
*/
|
|
async function extractJpegFromPdf(pdfBuf: Buffer): Promise<Buffer> {
|
|
// Intento 1: pdf-lib (semántico, correcto)
|
|
try {
|
|
const doc = await PDFDocument.load(pdfBuf, { ignoreEncryption: true })
|
|
for (const [, obj] of doc.context.enumerateIndirectObjects()) {
|
|
if (obj instanceof PDFRawStream) {
|
|
const subtype = obj.dict.lookupMaybe(PDFName.of('Subtype'), PDFName)
|
|
const filter = obj.dict.lookupMaybe(PDFName.of('Filter'), PDFName)
|
|
if (subtype?.toString() === '/Image' && filter?.toString() === '/DCTDecode') {
|
|
return Buffer.from(obj.contents)
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// pdf-lib no pudo parsear — caer a método binario
|
|
}
|
|
|
|
// Intento 2: búsqueda binaria de marcador JPEG SOI (FF D8 FF)
|
|
const soi = Buffer.from([0xff, 0xd8, 0xff])
|
|
const start = pdfBuf.indexOf(soi)
|
|
if (start === -1) throw new Error('No se encontró ningún JPEG en el PDF')
|
|
|
|
const endstreamBuf = Buffer.from('endstream')
|
|
const endstreamPos = pdfBuf.indexOf(endstreamBuf, start)
|
|
if (endstreamPos === -1) throw new Error('No se encontró "endstream" después del JPEG')
|
|
|
|
// Buscar EOI (FF D9) retrocediendo desde 'endstream'
|
|
let eoi = endstreamPos - 1
|
|
while (eoi > start + 2) {
|
|
if (pdfBuf[eoi] === 0xd9 && pdfBuf[eoi - 1] === 0xff) { eoi++; break }
|
|
eoi--
|
|
}
|
|
|
|
const jpeg = pdfBuf.subarray(start, eoi)
|
|
if (jpeg.length < 100) throw new Error(`JPEG extraído muy pequeño: ${jpeg.length} bytes`)
|
|
return jpeg
|
|
}
|
|
|
|
/**
|
|
* Cuenta píxeles por categoría de color en un buffer JPEG.
|
|
* Umbrales calibrados para los colores del heatmap con opacity 0.82 sobre #f8fafc.
|
|
*
|
|
* Verde (#10b981 + bg) → RGB ≈ (58, 197, 152) → G > R·1.4, G > 120
|
|
* Ámbar (#f59e0b + bg) → RGB ≈ (246, 175, 54) → R > 180, G > 120, B < 120, R/G < 1.8
|
|
* Rojo (#ef4444 + bg) → RGB ≈ (241, 101, 101) → R > G·1.8, R > B·1.8, R > 180
|
|
*/
|
|
async function analyzeJpegColors(jpegBuf: Buffer): Promise<{
|
|
greenPx: number; amberPx: number; redPx: number; total: number
|
|
}> {
|
|
const { data, info } = await sharp(jpegBuf).raw().toBuffer({ resolveWithObject: true })
|
|
const ch = info.channels // 3 para JPEG (RGB)
|
|
let greenPx = 0, amberPx = 0, redPx = 0
|
|
|
|
for (let i = 0; i < data.length; i += ch) {
|
|
const r = data[i], g = data[i + 1], b = data[i + 2]
|
|
|
|
if (g > r * 1.4 && g > 120 && g > b) {
|
|
greenPx++ // Verde
|
|
} else if (r > 180 && g > 120 && b < 120 && r > g && r < g * 1.8) {
|
|
amberPx++ // Ámbar / amarillo cálido
|
|
} else if (r > g * 1.8 && r > b * 1.8 && r > 180) {
|
|
redPx++ // Rojo
|
|
}
|
|
}
|
|
|
|
return { greenPx, amberPx, redPx, total: data.length / ch }
|
|
}
|
|
|
|
// ─── Test: gradiente real con costos dispares ─────────────────────────────────
|
|
|
|
test('Heatmap con gradiente real — medium-with-gateways.bpmn', async ({ page }) => {
|
|
// Costos dispares por bpmnElementId para forzar varianza significativa.
|
|
// Probabilidades de ejecución del proceso: task_recv/valid/analisis/archivo = 1.0,
|
|
// los demás ≈ 0.5. Se asignan costos para que:
|
|
// task_recv → expectedCost $10.000 → costo más alto → ROJO
|
|
// task_analisis → expectedCost $5.000 → mitad del max → ÁMBAR (t ≈ 0.5)
|
|
// resto → expectedCost < $500 → costos bajos → VERDE
|
|
const COST_BY_ELEMENT: Record<string, number> = {
|
|
task_recv: 10_000, // prob 1.0 → expected $10.000 → ROJO
|
|
task_valid: 200, // prob 1.0 → expected $200
|
|
task_score: 200, // prob 0.5 → expected $100
|
|
task_pedir_docs: 200, // prob 0.5 → expected $100
|
|
task_bureau: 200, // prob 0.5 → expected $100
|
|
task_empleador: 200, // prob 0.5 → expected $100
|
|
task_analisis: 5_000, // prob 1.0 → expected $5.000 → ÁMBAR
|
|
task_aprobar: 200, // prob 0.5 → expected $100
|
|
task_rechazar: 200, // prob 0.5 → expected $100
|
|
task_desembolso: 200, // prob 0.5 → expected $100
|
|
task_archivo: 200, // prob 1.0 → expected $200 → VERDE (mínimo)
|
|
}
|
|
|
|
// ── 1. Importar BPMN y navegar al workspace ─────────────────────────────────
|
|
await page.goto('/import')
|
|
await page.locator('#bpmn-file-input').setInputFiles(resolve(BPMN_DIR, 'medium-with-gateways.bpmn'))
|
|
await page.waitForURL(/\/workspace\//)
|
|
|
|
const processId = page.url().split('/workspace/')[1]
|
|
|
|
// ── 2. Inyectar costos dispares directamente en IndexedDB (Dexie) ───────────
|
|
await page.evaluate(async ({ procId, costs }) => {
|
|
const DB_NAME = 'ProcessCostPlatform'
|
|
|
|
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
|
const req = indexedDB.open(DB_NAME)
|
|
req.onsuccess = () => resolve(req.result)
|
|
req.onerror = () => reject(req.error)
|
|
})
|
|
|
|
const activities = await new Promise<any[]>((resolve, reject) => {
|
|
const tx = db.transaction('activities', 'readonly')
|
|
const index = tx.objectStore('activities').index('processId')
|
|
const req = index.getAll(procId)
|
|
req.onsuccess = () => resolve(req.result)
|
|
req.onerror = () => reject(req.error)
|
|
})
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const tx = db.transaction('activities', 'readwrite')
|
|
const store = tx.objectStore('activities')
|
|
let pending = activities.length
|
|
if (pending === 0) { resolve(); return }
|
|
|
|
for (const act of activities) {
|
|
const req = store.put({
|
|
...act,
|
|
directCostFixed: (costs as Record<string, number>)[act.bpmnElementId] ?? 200,
|
|
executionTimeMinutes: 60,
|
|
})
|
|
req.onsuccess = () => { if (--pending === 0) resolve() }
|
|
req.onerror = () => reject(req.error)
|
|
}
|
|
})
|
|
|
|
db.close()
|
|
}, { procId: processId, costs: COST_BY_ELEMENT })
|
|
|
|
// ── 3. Recargar workspace para que Zustand relea desde Dexie ────────────────
|
|
await page.reload()
|
|
await page.waitForSelector('button:has-text("Simular")', { timeout: 15_000 })
|
|
await page.waitForTimeout(1_500) // esperar carga de store
|
|
|
|
// ── 4. Simular y navegar al reporte ────────────────────────────────────────
|
|
const simularBtn = page.getByRole('button', { name: 'Simular' })
|
|
await expect(simularBtn).not.toBeDisabled({ timeout: 5_000 })
|
|
await simularBtn.click()
|
|
await page.waitForURL(/\/report\//, { timeout: 20_000 })
|
|
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 20_000 })
|
|
|
|
// ── 5. Esperar a que el heatmap tenga colores reales (no neutro #cbd5e1) ────
|
|
// Usamos style.fill (inline CSS) porque applyHeatmapToViewer ahora lo usa
|
|
// en vez de setAttribute (para garantizar mayor especificidad que bpmn-js CSS).
|
|
await page.waitForFunction(() => {
|
|
const rects = Array.from(document.querySelectorAll('.bpmn-container .djs-visual rect'))
|
|
return rects.some((r) => {
|
|
const fill = (r as HTMLElement).style.fill
|
|
return fill && fill !== '#cbd5e1'
|
|
})
|
|
}, { timeout: 30_000 })
|
|
|
|
// Capturar estado de los fills para diagnóstico
|
|
const fillsSnapshot = await page.evaluate(() =>
|
|
Array.from(document.querySelectorAll('.bpmn-container .djs-visual rect'))
|
|
.map((r) => (r as HTMLElement).style.fill || r.getAttribute('fill'))
|
|
)
|
|
console.log('\n🎨 Fills del heatmap:', fillsSnapshot)
|
|
|
|
// Screenshot para evidencia visual
|
|
await page.screenshot({ path: resolve(OUTPUT_DIR, 'medium-with-gradient-report.png'), fullPage: false })
|
|
|
|
// ── 6. Exportar PDF ─────────────────────────────────────────────────────────
|
|
const pdfPath = resolve(OUTPUT_DIR, 'medium-with-gradient.pdf')
|
|
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
|
|
await page.getByRole('button', { name: 'Exportar PDF' }).click()
|
|
const dl = await pdfDownload
|
|
await dl.saveAs(pdfPath)
|
|
|
|
// ── 7. Verificar tamaño ─────────────────────────────────────────────────────
|
|
const pdfBuf = readFileSync(pdfPath)
|
|
const pdfKB = (pdfBuf.length / 1024).toFixed(1)
|
|
console.log(`\n📄 medium-with-gradient.pdf: ${pdfBuf.length} bytes (${pdfKB} KB)`)
|
|
|
|
// JPEG de colores sólidos comprime casi igual que el gris neutro.
|
|
// El umbral mínimo es > 50 KB (PDF sin JPEG pesa ~28 KB; con JPEG ~60-80 KB).
|
|
expect(pdfBuf.length, 'PDF con gradiente real debe ser > 50 KB').toBeGreaterThan(50_000)
|
|
|
|
// ── 8. Extraer JPEG y analizar píxeles ──────────────────────────────────────
|
|
const jpegBuf = await extractJpegFromPdf(pdfBuf)
|
|
console.log(` JPEG extraído: ${jpegBuf.length} bytes`)
|
|
|
|
const { greenPx, amberPx, redPx, total } = await analyzeJpegColors(jpegBuf)
|
|
console.log(` Total píxeles: ${total.toLocaleString()}`)
|
|
console.log(` 🟢 Verde: ${greenPx.toLocaleString()} px`)
|
|
console.log(` 🟡 Ámbar: ${amberPx.toLocaleString()} px`)
|
|
console.log(` 🔴 Rojo: ${redPx.toLocaleString()} px`)
|
|
|
|
const MIN_PX = 100
|
|
expect(greenPx, `Píxeles verdes en heatmap (≥ ${MIN_PX}): ${greenPx}`).toBeGreaterThanOrEqual(MIN_PX)
|
|
expect(amberPx, `Píxeles ámbar en heatmap (≥ ${MIN_PX}): ${amberPx}`).toBeGreaterThanOrEqual(MIN_PX)
|
|
expect(redPx, `Píxeles rojos en heatmap (≥ ${MIN_PX}): ${redPx}`).toBeGreaterThanOrEqual(MIN_PX)
|
|
})
|