412 lines
22 KiB
TypeScript
412 lines
22 KiB
TypeScript
/**
|
||
* 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)
|
||
})
|