Files
inq-roi-simulador-web/tests/e2e/validate-dod-etapa4.spec.ts
2026-06-23 22:33:01 -03:00

466 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, existsSync } 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'
import { createClient } from '@supabase/supabase-js'
test.use({ storageState: 'tests/e2e/setup/auth-state.json' })
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const ROOT = resolve(__dirname, '../..')
const BPMN_DIR = resolve(__dirname, '../../public/sample-processes')
const OUTPUT_DIR = resolve(__dirname, '__output__')
// ─── Cliente Supabase con service_role_key para inyección directa de datos ────
// Reemplaza la inyección vía IndexedDB (Dexie, eliminado en Sprint 4) por escritura
// directa en Supabase, bypaseando RLS con el service_role_key. Ver .env.example.
function loadEnvFile(filename: string): Record<string, string> {
const path = resolve(ROOT, filename)
if (!existsSync(path)) return {}
const out: Record<string, string> = {}
for (const line of readFileSync(path, 'utf-8').split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const idx = trimmed.indexOf('=')
if (idx === -1) continue
out[trimmed.slice(0, idx)] = trimmed.slice(idx + 1)
}
return out
}
const localEnv = loadEnvFile('.env.local')
const testEnv = loadEnvFile('.env.test')
const SUPABASE_URL = localEnv.VITE_SUPABASE_URL
const SERVICE_ROLE_KEY = testEnv.SUPABASE_SERVICE_ROLE_KEY
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
}
// Gradiente 4 stops — colores de referencia con opacity 0.82 sobre fondo #f8fafc
const HEAT_REF_COLORS = {
green: { r: 58, g: 197, b: 151 },
amber: { r: 246, g: 175, b: 54 },
orange: { r: 249, g: 139, b: 63 },
red: { r: 241, g: 101, b: 101 },
} as const
const MAX_HEAT_COLOR_DIST = 40
function colorDistance(a: { r: number; g: number; b: number }, b: { r: number; g: number; b: number }): number {
return Math.sqrt((a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2)
}
async function analyzeJpegColors(jpegBuf: Buffer): Promise<{
greenPx: number; amberPx: number; orangePx: number; redPx: number; total: number
}> {
const { data, info } = await sharp(jpegBuf).raw().toBuffer({ resolveWithObject: true })
const ch = info.channels
let greenPx = 0, amberPx = 0, orangePx = 0, redPx = 0
for (let i = 0; i < data.length; i += ch) {
const px = { r: data[i], g: data[i + 1], b: data[i + 2] }
let closestName: keyof typeof HEAT_REF_COLORS | null = null
let closestDist = Infinity
for (const [name, ref] of Object.entries(HEAT_REF_COLORS) as Array<[keyof typeof HEAT_REF_COLORS, typeof HEAT_REF_COLORS['green']]>) {
const dist = colorDistance(px, ref)
if (dist < closestDist) { closestDist = dist; closestName = name }
}
if (closestDist > MAX_HEAT_COLOR_DIST) continue
if (closestName === 'green') greenPx++
else if (closestName === 'amber') amberPx++
else if (closestName === 'orange') orangePx++
else if (closestName === 'red') redPx++
}
return { greenPx, amberPx, orangePx, 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.describe('DoD Etapa 4', () => {
let createdProcessId: string | null = null
test.afterAll(async () => {
if (createdProcessId && SUPABASE_URL && SERVICE_ROLE_KEY) {
const supabaseAdmin = createClient(SUPABASE_URL, SERVICE_ROLE_KEY)
await supabaseAdmin.from('processes').delete().eq('id', createdProcessId)
}
})
test('DoD Etapa 4 — genera y valida los 6 archivos de export para medium-with-gateways', async ({ page }) => {
test.skip(
!SUPABASE_URL || !SERVICE_ROLE_KEY,
'SUPABASE_SERVICE_ROLE_KEY no configurada en .env.test — ver .env.example para el formato esperado'
)
// ── Fase 1: importar BPMN e inyectar configuración en Supabase ──────────────
await page.goto('/import')
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]
createdProcessId = processId
await page.waitForTimeout(1_500)
// Inyectar costos de actividades y flags de automatización directamente en Supabase
// (service_role_key bypasa RLS — equivalente al patrón antiguo de escritura en IndexedDB)
const supabaseAdmin = createClient(SUPABASE_URL!, SERVICE_ROLE_KEY!)
const { data: acts, error: actsErr } = await supabaseAdmin
.from('activities')
.select('id, bpmn_element_id')
.eq('process_id', processId)
if (actsErr) throw actsErr
const { error: upsertActsErr } = await supabaseAdmin.from('activities').upsert(
(acts ?? []).map((act) => {
const isAuto = AUTOMATABLE.has(act.bpmn_element_id as string)
return {
id: act.id,
direct_cost_fixed: ACTUAL_COST[act.bpmn_element_id as string] ?? 200,
execution_time_minutes: 60,
automatable: isAuto,
automated_cost_fixed: isAuto ? AUTO_COST : 0,
automated_time_minutes: isAuto ? AUTO_TIME : 0,
}
})
)
if (upsertActsErr) throw upsertActsErr
// Inyectar volumetría en el proceso
const { error: procErr } = await supabaseAdmin
.from('processes')
.update({
annual_frequency: 12_000,
analysis_horizon_years: 3,
automation_investment: 50_000,
})
.eq('id', processId)
if (procErr) throw procErr
// Recargar para que el store de Zustand relea desde Supabase
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 })
// Forzar modo Min-max: los costos del fixture están diseñados para posicionarse
// proporcionalmente en el rango (task_analisis a la mitad). El modo 'percentile'
// (default desde Sprint 5 Etapa 3) normaliza por rank y rompería la posición esperada.
await page.getByRole('button', { name: 'Min-max' }).click()
await page.waitForTimeout(300)
// ── 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(5)}px Ámbar:${actualPixels.amberPx.toLocaleString().padStart(5)}px Naranja:${actualPixels.orangePx.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(5)}px Ámbar:${autoPixels.amberPx.toLocaleString().padStart(5)}px Naranja:${autoPixels.orangePx.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)
})
})