feat: MVP Fase 0 — Process Cost Platform v0.1.0
Plataforma completa de análisis de costos operativos basada en BPMN 2.0. Funcionalidades incluidas: - Import de archivos BPMN 2.0 por drag & drop + 3 procesos de ejemplo - Visualización read-only del diagrama (bpmn-js) - Configuración de actividades (costo, tiempo, recursos asignados) - CRUD de recursos (rol, persona, sistema, equipo, insumo) - Configuración global (moneda, overhead %, nombre, cliente) - Motor de simulación determinístico agregado con propagación de gateways XOR/AND - Reporte visual con mapa de calor en dos modos (relativo/absoluto) - Gráficos: KPIs, top actividades, composición, costo por recurso - Export PDF (jsPDF + html2canvas) y CSV (papaparse) - Persistencia en IndexedDB (Dexie) - 268 tests unitarios/integración + 6 E2E con Playwright - Deploy: https://process-cost-platform.pages.dev Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
418
tests/e2e/export-pdf.spec.ts
Normal file
418
tests/e2e/export-pdf.spec.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
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('/')
|
||||
|
||||
// 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('/')
|
||||
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)
|
||||
})
|
||||
Reference in New Issue
Block a user