Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Tier 1 — Automatizado Build: ✅ limpio Lint: ✅ limpio en archivos nuevos de Sprint 4 (separé resource-types.ts de resource-display.tsx para sacar 2 warnings de Fast Refresh; quedan 0 errores/warnings nuevos). Quedó 1 error preexistente en ReportPage.tsx (no tocado desde Sprint 3, fuera de scope) y ~102 warnings no-explicit-any preexistentes en tests viejos. Tests unitarios: ✅ 521/521 verdes Tests E2E: ⚠️ 43-44/48 verdes (varía levemente entre corridas) — detalle abajo Auditoría inicial vs. estado final Antes: 39/39 specs (100%) fallaban — ROJO-AUTH puro, todos por #bpmn-file-input nunca apareciendo (esperaban el flujo pre-Sprint-4 directamente en /). Decisión tomada (con tu input): Opción A — sesión real. Creaste el usuario martin.bernal@inquality.com.py; yo verifiqué que no tenía fila en public.users y se la inserté con platform_role='platform_admin'. Implementé tests/e2e/setup/auth-setup.ts como globalSetup de Playwright: hace signInWithPassword, inyecta la sesión real en localStorage con el formato exacto que usa supabase-js v2, y genera tests/e2e/setup/auth-state.json (gitignored) en cada corrida. Fix adicional necesario: todos los specs navegaban a / esperando el input de archivo ahí — ahora vive en /import. Corregí los 18 page.goto('/') → /import, incluyendo reescribir el flujo obsoleto de prod-smoke.spec.ts (esperaba una landing pre-auth que ya no existe). Resultado: 33→43-44 specs pasan. Quedan 5-6 ROJO-OTRO, con dos causas raíz identificadas (no relacionadas con auth, fuera del mandato de esta etapa que pide no reescribir lógica de tests): Botón "Guardar" en ActivityPanel queda deshabilitado (3 specs de PDF) — encontré algo extraño: el texto del botón dice "Guardar cambios" (que solo se renderiza si isDirty=true) pero Playwright lo sigue viendo disabled. Esto contradice la lógica simple de disabled={!isDirty} en el componente — sospecho un bug real de re-render en producción, pero diagnosticarlo a fondo requiere debugging en vivo. Recomiendo abrir esto como bug dedicado en un sprint futuro. 2 specs usan IndexedDB/Dexie directamente (export-pdf.spec.ts, validate-dod-etapa4.spec.ts) — Dexie se eliminó completamente en Etapa 4. Estos tests necesitan reescritura para inyectar datos vía Supabase en vez de IndexedDB — eso sí es "reescribir lógica de tests", explícitamente fuera de este alcance. Los resultados varían levemente entre corridas porque Playwright corre specs en paralelo bajo la misma cuenta de test compartida — puede haber interferencia de datos entre specs concurrentes. Sugiero como mejora futura (no implementada ahora): --workers=1 para diagnóstico determinístico. Entregables nuevos tests/e2e/setup/auth-setup.ts — global setup con sesión real tests/e2e/sprint-4-smoke.spec.ts — 9 tests (4 sin auth: login, redirects de /, /workspace, /recursos; 5 con auth: biblioteca sin 500, sin 406 en simulations, catálogo carga, avatar visible, import navega a workspace) docs/CHECKLIST_ENTREGA.md — las 4 tiers completas .env.test (gitignored) + .env.example documentado .gitignore actualizado (.env.test, auth-state.json) Polish Textos de OwnershipBadge y "Compartir con equipo" ya estaban en español — sin cambios. Tooltip "Disponible próximamente" para "Compartir con equipo": no lo agregué, tal como pedía el propio brief, consultarte antes. ¿Lo querés?
414 lines
22 KiB
TypeScript
414 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'
|
||
|
||
test.use({ storageState: 'tests/e2e/setup/auth-state.json' })
|
||
|
||
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('/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]
|
||
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)
|
||
})
|