Antes de la última revisión de etapa 4. Sprint 1. Previo al ajuste de look n feel de app, reportes, nombre y colores.

This commit is contained in:
2026-05-15 22:14:53 -03:00
parent b6f31f4371
commit 8b71c6ee13
54 changed files with 6497 additions and 261 deletions

View File

@@ -0,0 +1,280 @@
/**
* E2E: Verifica el export PDF y CSV del tab "Comparación + ROI".
*
* Flujo completo:
* 1. Importar medium-with-gateways
* 2. Configurar task_recv como automatizable ($50 de costo, 5 min)
* 3. Configurar volumetría e inversión en el tab Global
* 4. Simular ambos escenarios
* 5. Navegar al tab "Comparación + ROI"
* 6. Exportar PDF → verificar tamaño, páginas, keywords, acentos
* 7. Exportar CSV → verificar BOM, 16 columnas, metadata ROI
*
* Test adicional: con BPMN sin automatizables, los tabs ROI y Automatizado
* están disabled y los botones de export funcionan para el tab actual.
*
* Ejecutar: npx playwright test tests/e2e/export-roi.spec.ts
*/
import { test, expect } from '@playwright/test'
import { readFileSync, mkdirSync } from 'fs'
import { resolve } from 'path'
import { fileURLToPath } from 'url'
import { PDFParse } from 'pdf-parse'
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 })
})
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: importar BPMN y llegar al workspace ──────────────────────────────
async function importBpmn(page: import('@playwright/test').Page, bpmnFile: string) {
await page.goto('/')
const fileInput = page.locator('#bpmn-file-input')
await fileInput.setInputFiles(resolve(BPMN_DIR, bpmnFile))
await page.waitForURL(/\/workspace\//, { timeout: 15_000 })
await page.waitForSelector('button:has-text("Simular")', { timeout: 15_000 })
await page.waitForTimeout(1_500)
}
// ─── Helper: configurar una actividad como automatizable ──────────────────────
async function makeAutomatable(
page: import('@playwright/test').Page,
elementId: string,
cost: number,
timeMin: number
) {
// Click en el elemento BPMN en el canvas
await page.locator(`[data-element-id="${elementId}"]`).first().click()
await page.waitForTimeout(500)
// Activar el toggle de automatizable
const toggle = page.getByRole('switch', { name: /automatizable/i })
if (await toggle.getAttribute('aria-checked') === 'false') {
await toggle.click()
await page.waitForTimeout(300)
}
// Ingresar costo automatizado
const costInput = page.getByLabel(/Costo automatizado/i)
await costInput.fill(String(cost))
// Ingresar tiempo automatizado
const timeInput = page.getByLabel(/Tiempo automatizado/i)
await timeInput.fill(String(timeMin))
// Guardar
const guardarBtn = page.getByRole('button', { name: /Guardar/i })
await guardarBtn.click()
await page.waitForTimeout(500)
}
// ─── Helper: configurar volumetría en tab Global ──────────────────────────────
async function configureGlobal(
page: import('@playwright/test').Page,
annualFrequency: number,
investment: number
) {
// Ir al tab Global
const globalTab = page.getByRole('tab', { name: /Global/i })
await globalTab.click()
await page.waitForTimeout(300)
// Frecuencia anual
const freqInput = page.getByLabel(/Frecuencia anual/i)
await freqInput.fill(String(annualFrequency))
// Inversión
const investInput = page.getByLabel(/Inversión en automatización/i)
await investInput.fill(String(investment))
// Guardar
const guardarBtn = page.getByRole('button', { name: /Guardar/i })
await guardarBtn.click()
await page.waitForTimeout(500)
}
// ─── Helper: simular y llegar al reporte ──────────────────────────────────────
async function simulateAndGoToReport(page: import('@playwright/test').Page) {
const simBtn = page.getByRole('button', { name: 'Simular' })
await expect(simBtn).not.toBeDisabled({ timeout: 5_000 })
await simBtn.click()
await page.waitForURL(/\/report\//, { timeout: 20_000 })
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 30_000 })
await page.waitForSelector('.bpmn-container .djs-group', { timeout: 30_000 })
}
// ─── Test principal: flujo ROI completo ───────────────────────────────────────
test('Export ROI — flujo completo con medium-with-gateways', async ({ page }) => {
await importBpmn(page, 'medium-with-gateways.bpmn')
// Configurar task_recv como automatizable ($50, 5 min)
await makeAutomatable(page, 'task_recv', 50, 5)
// Configurar volumetría e inversión
await configureGlobal(page, 12_000, 50_000)
// Simular
await simulateAndGoToReport(page)
// Navegar al tab ROI
const roiTab = page.getByRole('tab', { name: /Comparación/i })
await expect(roiTab).not.toBeDisabled({ timeout: 5_000 })
await roiTab.click()
await page.waitForTimeout(500)
// ── PDF ROI ────────────────────────────────────────────────────────────────
const pdfPath = resolve(OUTPUT_DIR, 'roi-test.pdf')
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
await page.getByRole('button', { name: 'Exportar PDF' }).click()
const dlPdf = await pdfDownload
await dlPdf.saveAs(pdfPath)
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 30_000 })
const pdfBuffer = readFileSync(pdfPath)
const pdfBytes = pdfBuffer.length
console.log(`\n📊 ROI PDF: ${pdfBytes} bytes (${(pdfBytes / 1024).toFixed(1)} KB)`)
// Tamaño razonable: < 400 KB
expect(pdfBytes).toBeGreaterThan(30_000)
expect(pdfBytes).toBeLessThan(400_000)
// Nombre con sufijo _roi
const downloadName = dlPdf.suggestedFilename()
console.log(` Nombre archivo: ${downloadName}`)
expect(downloadName).toMatch(/_roi\.pdf$/)
// Contenido mínimo
const { numpages, text } = await parsePdf(pdfBuffer)
console.log(` Páginas: ${numpages}`)
console.log(` Primeros 300 chars: ${text.slice(0, 300).replace(/\n/g, ' ')}`)
// Al menos 3 páginas (hay 4 en el ROI PDF)
expect(numpages).toBeGreaterThanOrEqual(3)
// Keywords que deben estar en el texto del PDF
expect(text).toMatch(/ROI|retorno|automatizaci/i)
expect(text).toMatch(/Payback|payback|recupera/i)
expect(text).toMatch(/ahorro|Ahorro/i)
expect(text).toContain('USD')
// ── CSV ROI ────────────────────────────────────────────────────────────────
const csvPath = resolve(OUTPUT_DIR, 'roi-test.csv')
const csvDownload = page.waitForEvent('download', { timeout: 30_000 })
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`)
console.log(` Nombre archivo CSV: ${dlCsv.suggestedFilename()}`)
// Nombre con sufijo _roi
expect(dlCsv.suggestedFilename()).toMatch(/_roi\.csv$/)
const csvText = csvBuffer.toString('utf-8')
// BOM UTF-8
expect(csvText).toMatch(/^/)
// Metadata ROI presente
expect(csvText).toContain('# Ahorro por ejecución')
expect(csvText).toContain('# Payback (meses)')
expect(csvText).toContain('# ROI anualizado')
expect(csvText).toContain('# Frecuencia anual: 12000')
// 16 columnas en el header de datos
const csvNoBom = csvText.startsWith('') ? csvText.slice(1) : csvText
const headerLine = csvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
const colCount = headerLine.split('","').length
console.log(` Columnas CSV: ${colCount}`)
expect(colCount).toBe(16)
// Columna Automatizable presente
expect(csvText).toContain('Automatizable')
})
// ─── Test: sin automatizables → tab ROI disabled, export de "Actual" OK ──────
test('Tabs ROI/Automatizado disabled cuando no hay actividades automatizables', async ({ page }) => {
await importBpmn(page, 'simple-linear.bpmn')
await simulateAndGoToReport(page)
// Los tabs deben estar disabled
const autoTab = page.getByRole('tab', { name: /Automatizado/i })
const roiTab = page.getByRole('tab', { name: /Comparación/i })
await expect(autoTab).toBeDisabled()
await expect(roiTab).toBeDisabled()
// El export PDF/CSV del tab "Actual" sigue funcionando
const pdfPath = resolve(OUTPUT_DIR, 'no-automatable.pdf')
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
await page.getByRole('button', { name: 'Exportar PDF' }).click()
const dlPdf = await pdfDownload
await dlPdf.saveAs(pdfPath)
const downloadName = dlPdf.suggestedFilename()
console.log(`\n📄 Sin automatizables — nombre PDF: ${downloadName}`)
expect(downloadName).toMatch(/_actual\.pdf$/)
const pdfBuffer = readFileSync(pdfPath)
expect(pdfBuffer.length).toBeGreaterThan(30_000)
const csvDownload = page.waitForEvent('download', { timeout: 30_000 })
await page.getByRole('button', { name: 'Exportar CSV' }).click()
const dlCsv = await csvDownload
console.log(` Nombre CSV: ${dlCsv.suggestedFilename()}`)
expect(dlCsv.suggestedFilename()).toMatch(/_actual\.csv$/)
})
// ─── Test: tab Automatizado → PDF y CSV con sufijo correcto ───────────────────
test('Export Automatizado — sufijo _automatizado en nombres de archivo', async ({ page }) => {
await importBpmn(page, 'medium-with-gateways.bpmn')
await makeAutomatable(page, 'task_recv', 30, 5)
await simulateAndGoToReport(page)
// Ir al tab Automatizado
const autoTab = page.getByRole('tab', { name: /^Automatizado$/i })
await expect(autoTab).not.toBeDisabled()
await autoTab.click()
await page.waitForTimeout(500)
// PDF con sufijo _automatizado
const pdfPath = resolve(OUTPUT_DIR, 'automatizado.pdf')
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
await page.getByRole('button', { name: 'Exportar PDF' }).click()
const dlPdf = await pdfDownload
await dlPdf.saveAs(pdfPath)
const pdfName = dlPdf.suggestedFilename()
console.log(`\n⚙ Automatizado PDF: ${pdfName}`)
expect(pdfName).toMatch(/_automatizado\.pdf$/)
const pdfBuffer = readFileSync(pdfPath)
expect(pdfBuffer.length).toBeGreaterThan(30_000)
// CSV con sufijo _automatizado
const csvDownload = page.waitForEvent('download', { timeout: 30_000 })
await page.getByRole('button', { name: 'Exportar CSV' }).click()
const dlCsv = await csvDownload
expect(dlCsv.suggestedFilename()).toMatch(/_automatizado\.csv$/)
})

View File

@@ -0,0 +1,411 @@
/**
* Validación programática del DoD de Etapa 4 — Export PDF y CSV con ROI.
*
* Genera los 6 archivos para medium-with-gateways con configuración representativa:
* - 3 actividades automatable: task_recv, task_score, task_analisis
* - Costos actuales dispares (para heatmap con gradiente real)
* - Costos automatizados: $20 / 5 min para las automatable
* - Volumetría: annualFrequency=12000, horizon=3, investment=50000
*
* Archivos generados en tests/e2e/__output__/:
* medium-gw_actual.pdf / medium-gw_actual.csv
* medium-gw_automatizado.pdf / medium-gw_automatizado.csv
* medium-gw_roi.pdf / medium-gw_roi.csv
*
* Ejecutar: npx playwright test tests/e2e/validate-dod-etapa4.spec.ts
*/
import { test, expect } from '@playwright/test'
import { readFileSync, mkdirSync, writeFileSync } 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 })
})
// ─── Helpers (mismos que export-pdf.spec.ts) ──────────────────────────────────
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 }
}
async function extractJpegFromPdf(pdfBuf: Buffer): Promise<Buffer> {
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 { /* fallback */ }
const soi = Buffer.from([0xff, 0xd8, 0xff])
const start = pdfBuf.indexOf(soi)
if (start === -1) throw new Error('No se encontró 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')
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 muy pequeño: ${jpeg.length} bytes`)
return jpeg
}
async function analyzeJpegColors(jpegBuf: Buffer) {
const { data, info } = await sharp(jpegBuf).raw().toBuffer({ resolveWithObject: true })
const ch = info.channels
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++
else if (r > 180 && g > 120 && b < 120 && r > g && r < g * 1.8) amberPx++
else if (r > g * 1.8 && r > b * 1.8 && r > 180) redPx++
}
return { greenPx, amberPx, redPx, total: data.length / ch }
}
// ─── Configuración del proceso ────────────────────────────────────────────────
// Costos dispares para heatmap con gradiente real (mismo patrón que export-pdf.spec.ts)
const ACTUAL_COST: Record<string, number> = {
task_recv: 10_000, // prob 1.0 → $10.000 → ROJO
task_valid: 200, // prob 1.0 → $200 → VERDE
task_score: 400, // prob 0.5 → $200 → VERDE
task_pedir_docs: 200, // prob 0.5 → $100 → VERDE
task_bureau: 200, // prob 0.5 → $100 → VERDE
task_empleador: 200, // prob 0.5 → $100 → VERDE
task_analisis: 5_000, // prob 1.0 → $5.000 → ÁMBAR
task_aprobar: 200, // prob 0.5 → $100 → VERDE
task_rechazar: 200, // prob 0.5 → $100 → VERDE
task_desembolso: 200, // prob 0.5 → $100 → VERDE
task_archivo: 200, // prob 1.0 → $200 → VERDE
}
// Automatable: task_recv, task_score, task_analisis con costo=$20, tiempo=5min
const AUTOMATABLE = new Set(['task_recv', 'task_score', 'task_analisis'])
const AUTO_COST = 20
const AUTO_TIME = 5
// ─── Test único que genera y valida los 6 archivos ───────────────────────────
test('DoD Etapa 4 — genera y valida los 6 archivos de export para medium-with-gateways', async ({ page }) => {
// ── Fase 1: importar BPMN e inyectar configuración en IndexedDB ─────────────
await page.goto('/')
await page.locator('#bpmn-file-input').setInputFiles(resolve(BPMN_DIR, 'medium-with-gateways.bpmn'))
await page.waitForURL(/\/workspace\//, { timeout: 15_000 })
const processId = page.url().split('/workspace/')[1]
await page.waitForTimeout(1_500)
// Inyectar costos de actividades y flags de automatización en IndexedDB
await page.evaluate(async ({ procId, actualCosts, automatable, autoCost, autoTime }) => {
const DB_NAME = 'ProcessCostPlatform'
const db = await new Promise<IDBDatabase>((res, rej) => {
const req = indexedDB.open(DB_NAME)
req.onsuccess = () => res(req.result)
req.onerror = () => rej(req.error)
})
const acts = await new Promise<any[]>((res, rej) => {
const tx = db.transaction('activities', 'readonly')
const req = tx.objectStore('activities').index('processId').getAll(procId)
req.onsuccess = () => res(req.result)
req.onerror = () => rej(req.error)
})
await new Promise<void>((res, rej) => {
const tx = db.transaction('activities', 'readwrite')
const store = tx.objectStore('activities')
let pending = acts.length
if (pending === 0) { res(); return }
for (const act of acts) {
const isAuto = (automatable as string[]).includes(act.bpmnElementId)
const req = store.put({
...act,
directCostFixed: (actualCosts as Record<string, number>)[act.bpmnElementId] ?? 200,
executionTimeMinutes: 60,
automatable: isAuto,
automatedCostFixed: isAuto ? autoCost : 0,
automatedTimeMinutes: isAuto ? autoTime : 0,
})
req.onsuccess = () => { if (--pending === 0) res() }
req.onerror = () => rej(req.error)
}
})
// Inyectar volumetría en el proceso
const proc = await new Promise<any>((res, rej) => {
const tx = db.transaction('processes', 'readonly')
const req = tx.objectStore('processes').get(procId)
req.onsuccess = () => res(req.result)
req.onerror = () => rej(req.error)
})
if (proc) {
await new Promise<void>((res, rej) => {
const tx = db.transaction('processes', 'readwrite')
const req = tx.objectStore('processes').put({
...proc,
annualFrequency: 12_000,
analysisHorizonYears: 3,
automationInvestment: 50_000,
})
req.onsuccess = () => res()
req.onerror = () => rej(req.error)
})
}
db.close()
}, { procId: processId, actualCosts: ACTUAL_COST, automatable: [...AUTOMATABLE], autoCost: AUTO_COST, autoTime: AUTO_TIME })
// Recargar para que el store de Zustand relea desde Dexie
await page.reload()
await page.waitForSelector('button:has-text("Simular")', { timeout: 15_000 })
await page.waitForTimeout(1_500)
// ── Fase 2: simular y llegar al reporte ──────────────────────────────────────
const simBtn = page.getByRole('button', { name: 'Simular' })
await expect(simBtn).not.toBeDisabled({ timeout: 5_000 })
await simBtn.click()
await page.waitForURL(/\/report\//, { timeout: 20_000 })
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 30_000 })
await page.waitForSelector('.bpmn-container .djs-group', { timeout: 30_000 })
// Esperar a que el heatmap tenga colores reales
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 })
// ── Fase 3: exportar desde tab "Actual" ──────────────────────────────────────
// Tab "Actual" está activo por defecto
const actualPdfPath = resolve(OUTPUT_DIR, 'medium-gw_actual.pdf')
const actualCsvPath = resolve(OUTPUT_DIR, 'medium-gw_actual.csv')
let dl = await Promise.all([
page.waitForEvent('download', { timeout: 90_000 }),
page.getByRole('button', { name: 'Exportar PDF' }).click(),
])
await dl[0].saveAs(actualPdfPath)
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 20_000 })
let dlCsv = await Promise.all([
page.waitForEvent('download', { timeout: 30_000 }),
page.getByRole('button', { name: 'Exportar CSV' }).click(),
])
await dlCsv[0].saveAs(actualCsvPath)
// ── Fase 4: exportar desde tab "Automatizado" ─────────────────────────────────
await page.getByRole('tab', { name: /^Automatizado$/i }).click()
await page.waitForTimeout(1_000)
const autoPdfPath = resolve(OUTPUT_DIR, 'medium-gw_automatizado.pdf')
const autoCsvPath = resolve(OUTPUT_DIR, 'medium-gw_automatizado.csv')
dl = await Promise.all([
page.waitForEvent('download', { timeout: 90_000 }),
page.getByRole('button', { name: 'Exportar PDF' }).click(),
])
await dl[0].saveAs(autoPdfPath)
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 20_000 })
dlCsv = await Promise.all([
page.waitForEvent('download', { timeout: 30_000 }),
page.getByRole('button', { name: 'Exportar CSV' }).click(),
])
await dlCsv[0].saveAs(autoCsvPath)
// ── Fase 5: exportar desde tab "Comparación + ROI" ───────────────────────────
await page.getByRole('tab', { name: /Comparación/i }).click()
await page.waitForTimeout(1_000)
const roiPdfPath = resolve(OUTPUT_DIR, 'medium-gw_roi.pdf')
const roiCsvPath = resolve(OUTPUT_DIR, 'medium-gw_roi.csv')
dl = await Promise.all([
page.waitForEvent('download', { timeout: 90_000 }),
page.getByRole('button', { name: 'Exportar PDF' }).click(),
])
await dl[0].saveAs(roiPdfPath)
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 20_000 })
dlCsv = await Promise.all([
page.waitForEvent('download', { timeout: 30_000 }),
page.getByRole('button', { name: 'Exportar CSV' }).click(),
])
await dlCsv[0].saveAs(roiCsvPath)
// ══════════════════════════════════════════════════════════════════════════════
// ── VALIDACIÓN PROGRAMÁTICA ──────────────────────────────────────────────────
// ══════════════════════════════════════════════════════════════════════════════
const results: string[] = [
'╔══════════════════════════════════════════════════════════════════════╗',
'║ DoD Etapa 4 — Validación programática de los 6 archivos ║',
'╠══════════════════════════════════════════════════════════════════════╣',
]
// ── Validar actual.pdf ────────────────────────────────────────────────────────
const actualPdfBuf = readFileSync(actualPdfPath)
const { numpages: actualPages, text: actualText } = await parsePdf(actualPdfBuf)
const actualKb = (actualPdfBuf.length / 1024).toFixed(1)
const actualHasJpeg = actualPdfBuf.includes(Buffer.from([0xff, 0xd8, 0xff]))
const actualJpeg = await extractJpegFromPdf(actualPdfBuf)
const actualPixels = await analyzeJpegColors(actualJpeg)
const actualOk =
actualPdfBuf.length > 40_000 &&
actualPages >= 2 &&
actualText.includes('COSTO TOTAL') &&
actualHasJpeg &&
actualPixels.redPx >= 100 &&
actualPixels.greenPx >= 100
results.push(`║ actual.pdf │ ${actualKb.padEnd(6)} KB │ ${String(actualPages).padEnd(6)} pgs │ ${actualOk ? '✓ OK' : '✗ FAIL'}`)
results.push(`║ JPEG: ${actualHasJpeg ? '✓' : '✗'} Verde:${actualPixels.greenPx.toLocaleString().padStart(6)}px Ámbar:${actualPixels.amberPx.toLocaleString().padStart(5)}px Rojo:${actualPixels.redPx.toLocaleString().padStart(5)}px ║`)
// ── Validar automatizado.pdf ──────────────────────────────────────────────────
const autoPdfBuf = readFileSync(autoPdfPath)
const { numpages: autoPages, text: autoText } = await parsePdf(autoPdfBuf)
const autoKb = (autoPdfBuf.length / 1024).toFixed(1)
const autoHasJpeg = autoPdfBuf.includes(Buffer.from([0xff, 0xd8, 0xff]))
const autoJpeg = await extractJpegFromPdf(autoPdfBuf)
const autoPixels = await analyzeJpegColors(autoJpeg)
const autoOk =
autoPdfBuf.length > 40_000 &&
autoPages >= 2 &&
autoText.includes('COSTO TOTAL') &&
autoHasJpeg &&
autoPixels.greenPx >= 100
results.push(`║ automatizado.pdf│ ${autoKb.padEnd(6)} KB │ ${String(autoPages).padEnd(6)} pgs │ ${autoOk ? '✓ OK' : '✗ FAIL'}`)
results.push(`║ JPEG: ${autoHasJpeg ? '✓' : '✗'} Verde:${autoPixels.greenPx.toLocaleString().padStart(6)}px Ámbar:${autoPixels.amberPx.toLocaleString().padStart(5)}px Rojo:${autoPixels.redPx.toLocaleString().padStart(5)}px ║`)
// ── Validar roi.pdf ───────────────────────────────────────────────────────────
const roiPdfBuf = readFileSync(roiPdfPath)
const { numpages: roiPages, text: roiText } = await parsePdf(roiPdfBuf)
const roiKb = (roiPdfBuf.length / 1024).toFixed(1)
// El PDF de ROI NO debe tener heatmap JPEG (solo texto y tablas)
const roiHasJpeg = roiPdfBuf.includes(Buffer.from([0xff, 0xd8, 0xff]))
const roiOk =
roiPdfBuf.length > 20_000 &&
roiPages >= 3 &&
roiText.match(/retorno|ROI|ahorro/i) !== null &&
!roiHasJpeg // confirmamos que no tiene JPEG (spec correcto)
results.push(`║ roi.pdf │ ${roiKb.padEnd(6)} KB │ ${String(roiPages).padEnd(6)} pgs │ ${roiOk ? '✓ OK' : '✗ FAIL'}`)
results.push(`║ Sin heatmap JPEG (correcto): ${roiHasJpeg ? '✗ TIENE JPEG (inesperado)' : '✓ sin JPEG'}`)
// ── Validar actual.csv ────────────────────────────────────────────────────────
const actualCsvBuf = readFileSync(actualCsvPath)
const actualCsvText = actualCsvBuf.toString('utf-8')
const actualCsvBom = actualCsvBuf[0] === 0xEF || actualCsvText.charCodeAt(0) === 0xFEFF
const actualCsvNoBom = actualCsvText.startsWith('') ? actualCsvText.slice(1) : actualCsvText
const actualHeaderLine = actualCsvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
const actualCsvCols = actualHeaderLine ? actualHeaderLine.split('","').length : 0
const actualCsvOk = actualCsvBuf.length > 500 && actualCsvBom && actualCsvCols === 10
results.push(`║ actual.csv │ ${actualCsvBuf.length} B │ ${actualCsvCols} cols │ ${actualCsvOk ? '✓ OK' : '✗ FAIL'}`)
results.push(`║ BOM: ${actualCsvBom ? '✓' : '✗'} Columnas: ${actualCsvCols} (esperado: 10) ║`)
// ── Validar automatizado.csv ──────────────────────────────────────────────────
const autoCsvBuf = readFileSync(autoCsvPath)
const autoCsvText = autoCsvBuf.toString('utf-8')
const autoCsvBom = autoCsvBuf[0] === 0xEF || autoCsvText.charCodeAt(0) === 0xFEFF
const autoCsvNoBom = autoCsvText.startsWith('') ? autoCsvText.slice(1) : autoCsvText
const autoHeaderLine = autoCsvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
const autoCsvCols = autoHeaderLine ? autoHeaderLine.split('","').length : 0
const autoCsvHasEscenario = autoCsvText.includes('Automatizado')
const autoCsvOk = autoCsvBuf.length > 500 && autoCsvBom && autoCsvCols === 10 && autoCsvHasEscenario
results.push(`║ automatizado.csv│ ${autoCsvBuf.length} B │ ${autoCsvCols} cols │ ${autoCsvOk ? '✓ OK' : '✗ FAIL'}`)
results.push(`║ BOM: ${autoCsvBom ? '✓' : '✗'} Columnas: ${autoCsvCols} (esperado: 10) Escenario: ${autoCsvHasEscenario ? '✓' : '✗'}`)
// ── Validar roi.csv ───────────────────────────────────────────────────────────
const roiCsvBuf = readFileSync(roiCsvPath)
const roiCsvText = roiCsvBuf.toString('utf-8')
const roiCsvBom = roiCsvBuf[0] === 0xEF || roiCsvText.charCodeAt(0) === 0xFEFF
const roiCsvNoBom = roiCsvText.startsWith('') ? roiCsvText.slice(1) : roiCsvText
const roiHeaderLine = roiCsvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
const roiCsvCols = roiHeaderLine ? roiHeaderLine.split('","').length : 0
const roiHasPayback = roiCsvText.includes('Payback')
const roiHasROI = roiCsvText.includes('ROI anualizado')
const roiHasFreq = roiCsvText.includes('12000')
const roiHasAutomatable = roiCsvText.includes('Automatizable')
const roiCsvOk = roiCsvBuf.length > 1000 && roiCsvBom && roiCsvCols === 16 && roiHasPayback && roiHasROI
results.push(`║ roi.csv │ ${roiCsvBuf.length} B │ ${roiCsvCols} cols│ ${roiCsvOk ? '✓ OK' : '✗ FAIL'}`)
results.push(`║ BOM: ${roiCsvBom ? '✓' : '✗'} Cols: ${roiCsvCols}/16 Payback: ${roiHasPayback ? '✓' : '✗'} ROI: ${roiHasROI ? '✓' : '✗'} Freq: ${roiHasFreq ? '✓' : '✗'} AutoCol: ${roiHasAutomatable ? '✓' : '✗'}`)
results.push('╚══════════════════════════════════════════════════════════════════════╝')
console.log('\n' + results.join('\n'))
// ── Texto del roi.pdf — primeras 20 líneas ────────────────────────────────────
const roiLines = roiText.split('\n').filter((l) => l.trim()).slice(0, 20)
console.log('\n── Texto extraído roi.pdf (primeras 20 líneas) ──')
roiLines.forEach((l, i) => console.log(` ${String(i + 1).padStart(2)}: ${l}`))
// ── Guardar reporte de validación como artefacto ──────────────────────────────
writeFileSync(
resolve(OUTPUT_DIR, 'dod-etapa4-report.txt'),
results.join('\n') + '\n\n── roi.pdf (primeras 20 líneas) ──\n' + roiLines.join('\n')
)
// ── Assertions duras — cualquier falla detiene Etapa 5 ────────────────────────
expect(actualPdfBuf.length, 'actual.pdf debe ser > 40 KB').toBeGreaterThan(40_000)
expect(actualPages, 'actual.pdf debe tener ≥ 2 páginas').toBeGreaterThanOrEqual(2)
expect(actualText, 'actual.pdf debe contener "COSTO TOTAL"').toContain('COSTO TOTAL')
expect(actualHasJpeg, 'actual.pdf debe tener heatmap JPEG embebido').toBe(true)
expect(actualPixels.redPx, 'actual.pdf JPEG debe tener píxeles rojos (≥100)').toBeGreaterThanOrEqual(100)
expect(actualPixels.greenPx, 'actual.pdf JPEG debe tener píxeles verdes (≥100)').toBeGreaterThanOrEqual(100)
expect(autoPdfBuf.length, 'automatizado.pdf debe ser > 40 KB').toBeGreaterThan(40_000)
expect(autoPages, 'automatizado.pdf debe tener ≥ 2 páginas').toBeGreaterThanOrEqual(2)
expect(autoText, 'automatizado.pdf debe contener "COSTO TOTAL"').toContain('COSTO TOTAL')
expect(autoHasJpeg, 'automatizado.pdf debe tener heatmap JPEG embebido').toBe(true)
expect(autoPixels.greenPx, 'automatizado.pdf JPEG debe tener píxeles verdes (≥100)').toBeGreaterThanOrEqual(100)
expect(roiPdfBuf.length, 'roi.pdf debe ser > 20 KB').toBeGreaterThan(20_000)
expect(roiPages, 'roi.pdf debe tener ≥ 3 páginas').toBeGreaterThanOrEqual(3)
expect(roiText, 'roi.pdf debe contener keyword de ROI').toMatch(/retorno|ROI|ahorro/i)
expect(roiHasJpeg, 'roi.pdf NO debe contener heatmap JPEG (solo texto)').toBe(false)
expect(actualCsvBom, 'actual.csv BOM UTF-8').toBe(true)
expect(actualCsvCols, 'actual.csv debe tener 10 columnas').toBe(10)
expect(autoCsvBom, 'automatizado.csv BOM UTF-8').toBe(true)
expect(autoCsvCols, 'automatizado.csv debe tener 10 columnas').toBe(10)
expect(autoCsvHasEscenario, 'automatizado.csv debe indicar escenario Automatizado').toBe(true)
expect(roiCsvBom, 'roi.csv BOM UTF-8').toBe(true)
expect(roiCsvCols, 'roi.csv debe tener 16 columnas').toBe(16)
expect(roiHasPayback, 'roi.csv debe tener metadata de Payback').toBe(true)
expect(roiHasROI, 'roi.csv debe tener metadata de ROI anualizado').toBe(true)
expect(roiHasFreq, 'roi.csv debe tener frecuencia anual 12000').toBe(true)
expect(roiHasAutomatable, 'roi.csv debe tener columna Automatizable').toBe(true)
})