- OBSERVATIONS.md reseteado para Sprint 2 - STRATEGIC_DIRECTION.md con reflexiones del sprint - TECH_DEBT.md curado: items resueltos archivados - methodology/CASE_STUDIES/inq-roi-sprint-1-5.md generado Sprint 1.5 cerrado: - 11 etapas + 1 fix pre-Etapa 11 - 526 unit tests + 38 E2E al cierre - Producto con identidad InQ aplicada en web + 3 PDFs - Metodología documentada con casos reales (2 de mala clasificación de iniciativa, originaron AP.8 + refinamiento C.6) - Material para transmisión al equipo de 5 técnicos
419 lines
19 KiB
TypeScript
419 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'
|
|
|
|
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)
|
|
})
|