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

161
tests/domain/roi.test.ts Normal file
View File

@@ -0,0 +1,161 @@
/**
* Tests del módulo domain/roi.ts.
* Cubren: happy path, edge cases de inversión cero, ahorro cero/negativo,
* división por cero, escenarios de largo plazo y corto plazo.
* Todas las fórmulas son nominales simples (ADR-014).
*/
import { describe, it, expect } from 'vitest'
import { calculateRoi } from '@/domain/roi'
import type { RoiInput } from '@/domain/roi'
// ─── Fixture base ─────────────────────────────────────────────────────────────
function baseInput(overrides: Partial<RoiInput> = {}): RoiInput {
return {
costPerExecutionActual: 100,
costPerExecutionAutomated: 20,
annualFrequency: 1000,
analysisHorizonYears: 3,
automationInvestment: 50_000,
...overrides,
}
}
// ─── Happy path ───────────────────────────────────────────────────────────────
describe('calculateRoi — happy path', () => {
it('calcula ahorro por ejecución correctamente', () => {
const r = calculateRoi(baseInput())
expect(r.savingsPerExecution).toBeCloseTo(80)
})
it('calcula ahorro por ejecución en %', () => {
const r = calculateRoi(baseInput())
expect(r.savingsPerExecutionPercent).toBeCloseTo(80) // 80/100 = 80%
})
it('calcula ahorro anual = ahorro/ejecución × frecuencia', () => {
const r = calculateRoi(baseInput())
expect(r.annualSavings).toBeCloseTo(80_000) // 80 × 1000
})
it('calcula payback en meses correctamente', () => {
const r = calculateRoi(baseInput())
// $50.000 / $80.000/año × 12 = 7.5 meses
expect(r.paybackMonths).toBeCloseTo(7.5)
})
it('calcula payback en años', () => {
const r = calculateRoi(baseInput())
expect(r.paybackYears).toBeCloseTo(0.625)
})
it('calcula ahorro acumulado neto al horizonte', () => {
const r = calculateRoi(baseInput())
// ($80.000 × 3 años) - $50.000 = $190.000
expect(r.cumulativeSavingsHorizon).toBeCloseTo(190_000)
})
it('el payback dentro del horizonte es true cuando paybackYears < horizonYears', () => {
const r = calculateRoi(baseInput()) // payback ~0.625 < 3 años
expect(r.breaksEvenInHorizon).toBe(true)
})
it('calcula ROI anualizado %', () => {
const r = calculateRoi(baseInput())
// ($80.000 / $50.000) × 100 = 160%
expect(r.roiAnnualPercent).toBeCloseTo(160)
})
})
// ─── Edge case: inversión cero con ahorro positivo ────────────────────────────
describe('calculateRoi — inversión cero', () => {
it('payback es 0 meses (recuperación instantánea)', () => {
const r = calculateRoi(baseInput({ automationInvestment: 0 }))
expect(r.paybackMonths).toBe(0)
expect(r.paybackYears).toBe(0)
})
it('breaksEvenInHorizon es true', () => {
const r = calculateRoi(baseInput({ automationInvestment: 0 }))
expect(r.breaksEvenInHorizon).toBe(true)
})
it('roiAnnualPercent es Infinity', () => {
const r = calculateRoi(baseInput({ automationInvestment: 0 }))
expect(r.roiAnnualPercent).toBe(Infinity)
})
it('inversión cero con ahorro cero: roiAnnualPercent es 0', () => {
const r = calculateRoi(baseInput({
automationInvestment: 0,
costPerExecutionActual: 100,
costPerExecutionAutomated: 100,
}))
expect(r.roiAnnualPercent).toBe(0)
})
})
// ─── Edge case: ahorro cero o negativo ────────────────────────────────────────
describe('calculateRoi — ahorro negativo o cero', () => {
it('si el costo automatizado = costo actual, ahorro = 0', () => {
const r = calculateRoi(baseInput({ costPerExecutionAutomated: 100 }))
expect(r.savingsPerExecution).toBeCloseTo(0)
expect(r.annualSavings).toBeCloseTo(0)
})
it('ahorro cero → payback Infinity', () => {
const r = calculateRoi(baseInput({ costPerExecutionAutomated: 100 }))
expect(r.paybackMonths).toBe(Infinity)
expect(r.paybackYears).toBe(Infinity)
})
it('ahorro cero → breaksEvenInHorizon false', () => {
const r = calculateRoi(baseInput({ costPerExecutionAutomated: 100 }))
expect(r.breaksEvenInHorizon).toBe(false)
})
it('ahorro negativo (automatizado más caro): payback Infinity', () => {
const r = calculateRoi(baseInput({ costPerExecutionAutomated: 150 }))
expect(r.annualSavings).toBeLessThan(0)
expect(r.paybackMonths).toBe(Infinity)
expect(r.breaksEvenInHorizon).toBe(false)
})
it('ahorro negativo: cumulativeSavings también es negativo', () => {
const r = calculateRoi(baseInput({ costPerExecutionAutomated: 150 }))
expect(r.cumulativeSavingsHorizon).toBeLessThan(0)
})
})
// ─── Edge case: costo actual cero (evitar div/0) ──────────────────────────────
describe('calculateRoi — costo actual cero', () => {
it('savingsPerExecutionPercent es 0, no NaN ni Infinity', () => {
const r = calculateRoi(baseInput({
costPerExecutionActual: 0,
costPerExecutionAutomated: 0,
}))
expect(r.savingsPerExecutionPercent).toBe(0)
expect(Number.isFinite(r.savingsPerExecutionPercent)).toBe(true)
})
})
// ─── Escenario de largo plazo (horizonte 10 años) ─────────────────────────────
describe('calculateRoi — horizonte extendido', () => {
it('payback fuera del horizonte corto → breaksEvenInHorizon false', () => {
// savingsPerExec=10, freq=1000 → annualSavings=$10.000; payback=$500.000/$10.000=50 años
const r = calculateRoi(baseInput({
automationInvestment: 500_000,
costPerExecutionActual: 100,
costPerExecutionAutomated: 90,
annualFrequency: 1000,
}))
expect(r.paybackYears).toBeGreaterThan(3)
expect(r.breaksEvenInHorizon).toBe(false)
})
})

View File

@@ -89,6 +89,9 @@ describe('runSimulation', () => {
directCostFixed: directCost,
executionTimeMinutes: minutes,
assignedResources: resources,
automatable: false,
automatedCostFixed: 0,
automatedTimeMinutes: 0,
})
it('calcula costo total sin recursos — proceso lineal', () => {
@@ -208,3 +211,182 @@ describe('runSimulation', () => {
expect(result.perActivity[1].bpmnElementId).toBe('taskA')
})
})
// ─── Sprint 1: escenario 'automated' + actividades automatizables ─────────────
describe('runSimulation — scenario automated', () => {
const makeAutoActivity = (
bpmnElementId: string,
opts: {
directCost?: number; directTime?: number
automatable?: boolean; automatedCost?: number; automatedTime?: number
} = {}
): Activity => ({
id: `act-${bpmnElementId}`,
processId: 'proc1',
bpmnElementId,
name: bpmnElementId,
type: 'task',
directCostFixed: opts.directCost ?? 100,
executionTimeMinutes: opts.directTime ?? 60,
assignedResources: [],
automatable: opts.automatable ?? false,
automatedCostFixed: opts.automatedCost ?? 0,
automatedTimeMinutes: opts.automatedTime ?? 0,
})
// ─── scenario=automated con costos en cero ────────────────────────────────
it('scenario=automated con automatedCostFixed=0 y automatedTimeMinutes=0 devuelve cost=0 time=0 sin NaN', () => {
const act = makeAutoActivity('taskA', {
directCost: 500, directTime: 60,
automatable: true, automatedCost: 0, automatedTime: 0,
})
const result = runSimulation({
processXml: LINEAR_BPMN,
activities: new Map([['taskA', act], ['taskB', makeAutoActivity('taskB')]]),
gateways: new Map(),
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
scenario: 'automated',
})
const actResult = result.perActivity.find((a) => a.bpmnElementId === 'taskA')!
expect(actResult).toBeDefined()
expect(actResult.expectedDirectCost).toBe(0)
expect(actResult.executionTimeMinutes).toBe(0)
// Verificación explícita de ausencia de NaN en todos los campos numéricos
expect(Number.isNaN(actResult.expectedDirectCost)).toBe(false)
expect(Number.isNaN(actResult.expectedIndirectCost)).toBe(false)
expect(Number.isNaN(actResult.expectedTotalCost)).toBe(false)
expect(Number.isNaN(actResult.percentOfTotal)).toBe(false)
expect(Number.isNaN(result.totalCost)).toBe(false)
})
it('actividad NO automatable mantiene costos originales en scenario=automated', () => {
const act = makeAutoActivity('taskA', {
directCost: 200, directTime: 30,
automatable: false, automatedCost: 0, automatedTime: 0,
})
const result = runSimulation({
processXml: LINEAR_BPMN,
activities: new Map([['taskA', act], ['taskB', makeAutoActivity('taskB')]]),
gateways: new Map(),
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
scenario: 'automated',
})
const actResult = result.perActivity.find((a) => a.bpmnElementId === 'taskA')!
// automatable=false → usa costo original
expect(actResult.expectedDirectCost).toBeCloseTo(200)
expect(actResult.executionTimeMinutes).toBe(30)
})
// ─── La probabilidad de ejecución NO depende del scenario ────────────────
it('probabilidad de ejecución NO cambia entre scenario=actual y scenario=automated', () => {
// Usamos el BPMN XOR: taskB tiene prob 0.7, taskC tiene prob 0.3
const XOR_BPMN_LOCAL = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" targetNamespace="test">
<process id="p" isExecutable="false">
<startEvent id="start"><outgoing>f1</outgoing></startEvent>
<task id="taskA" name="A"><incoming>f1</incoming><outgoing>f2</outgoing></task>
<exclusiveGateway id="gw1"><incoming>f2</incoming><outgoing>f3</outgoing><outgoing>f4</outgoing></exclusiveGateway>
<task id="taskB" name="B"><incoming>f3</incoming><outgoing>f5</outgoing></task>
<task id="taskC" name="C"><incoming>f4</incoming><outgoing>f6</outgoing></task>
<endEvent id="end"><incoming>f5</incoming><incoming>f6</incoming></endEvent>
<sequenceFlow id="f1" sourceRef="start" targetRef="taskA"/>
<sequenceFlow id="f2" sourceRef="taskA" targetRef="gw1"/>
<sequenceFlow id="f3" sourceRef="gw1" targetRef="taskB"/>
<sequenceFlow id="f4" sourceRef="gw1" targetRef="taskC"/>
<sequenceFlow id="f5" sourceRef="taskB" targetRef="end"/>
<sequenceFlow id="f6" sourceRef="taskC" targetRef="end"/>
</process>
</definitions>`
const activities = new Map([
['taskA', makeAutoActivity('taskA', { automatable: true, automatedCost: 5, automatedTime: 2 })],
['taskB', makeAutoActivity('taskB', { automatable: true, automatedCost: 0, automatedTime: 0 })],
['taskC', makeAutoActivity('taskC', { automatable: false })],
])
const gateways = new Map([['gw1', {
id: 'gw1', processId: 'proc1', bpmnElementId: 'gw1', gatewayType: 'exclusive' as const,
branches: [
{ flowId: 'f3', targetElementId: 'taskB', probability: 0.7 },
{ flowId: 'f4', targetElementId: 'taskC', probability: 0.3 },
],
}]])
const baseInput = {
processXml: XOR_BPMN_LOCAL,
activities,
gateways,
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
}
const resultActual = runSimulation({ ...baseInput, scenario: 'actual' })
const resultAutomated = runSimulation({ ...baseInput, scenario: 'automated' })
// taskA tiene prob 1.0 en ambos
const aActual = resultActual.perActivity.find((a) => a.bpmnElementId === 'taskA')!
const aAuto = resultAutomated.perActivity.find((a) => a.bpmnElementId === 'taskA')!
expect(aActual.executionProbability).toBeCloseTo(1.0)
expect(aAuto.executionProbability).toBeCloseTo(1.0)
// taskB tiene prob 0.7 en ambos (el scenario no cambia el grafo)
const bActual = resultActual.perActivity.find((a) => a.bpmnElementId === 'taskB')!
const bAuto = resultAutomated.perActivity.find((a) => a.bpmnElementId === 'taskB')!
expect(bActual.executionProbability).toBeCloseTo(0.7)
expect(bAuto.executionProbability).toBeCloseTo(0.7)
// taskC tiene prob 0.3 en ambos
const cActual = resultActual.perActivity.find((a) => a.bpmnElementId === 'taskC')!
const cAuto = resultAutomated.perActivity.find((a) => a.bpmnElementId === 'taskC')!
expect(cActual.executionProbability).toBeCloseTo(0.3)
expect(cAuto.executionProbability).toBeCloseTo(0.3)
// Los costos SÍ cambian para actividades automatable=true
// taskB en actual: directCostFixed=100; en automated: automatedCostFixed=0
expect(bActual.expectedDirectCost).toBeGreaterThan(0)
expect(bAuto.expectedDirectCost).toBe(0)
// taskC no es automatable → costos iguales en ambos
expect(cActual.expectedDirectCost).toBeCloseTo(cAuto.expectedDirectCost)
})
it('NaN no aparece en ningún campo cuando TODOS los costos son cero', () => {
const activities = new Map([
['taskA', makeAutoActivity('taskA', { directCost: 0, directTime: 0, automatable: true, automatedCost: 0, automatedTime: 0 })],
['taskB', makeAutoActivity('taskB', { directCost: 0, directTime: 0, automatable: true, automatedCost: 0, automatedTime: 0 })],
])
for (const scenario of ['actual', 'automated'] as const) {
const result = runSimulation({
processXml: LINEAR_BPMN,
activities,
gateways: new Map(),
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0.2 },
scenario,
})
expect(Number.isNaN(result.totalCost)).toBe(false)
expect(Number.isNaN(result.totalDirectCost)).toBe(false)
expect(Number.isNaN(result.totalIndirectCost)).toBe(false)
for (const act of result.perActivity) {
expect(Number.isNaN(act.expectedDirectCost)).toBe(false)
expect(Number.isNaN(act.expectedIndirectCost)).toBe(false)
expect(Number.isNaN(act.expectedTotalCost)).toBe(false)
expect(Number.isNaN(act.percentOfTotal)).toBe(false)
expect(Number.isNaN(act.executionProbability)).toBe(false)
}
}
})
})

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)
})

View File

@@ -226,7 +226,8 @@ describe('MethodologyFooter', () => {
const proc = {
id: 'p1', name: 'Proceso Test', clientName: '',
bpmnXml: '<def/>', currency: 'USD',
overheadPercentage: 0.2, createdAt: 0, updatedAt: 0,
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0,
}
expect(() => render(<MethodologyFooter process={proc} />)).not.toThrow()
})
@@ -235,7 +236,8 @@ describe('MethodologyFooter', () => {
const proc = {
id: 'p1', name: 'Proc', clientName: '',
bpmnXml: '', currency: 'USD',
overheadPercentage: 0.25, createdAt: 0, updatedAt: 0,
overheadPercentage: 0.25, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0,
}
render(<MethodologyFooter process={proc} />)
const text = document.body.textContent ?? ''
@@ -246,7 +248,8 @@ describe('MethodologyFooter', () => {
const proc = {
id: 'p1', name: 'Proc', clientName: '',
bpmnXml: '', currency: 'USD',
overheadPercentage: 0.2, createdAt: 0, updatedAt: 0,
overheadPercentage: 0.2, annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0,
}
render(<MethodologyFooter process={proc} />)
const text = document.body.textContent ?? ''
@@ -326,6 +329,9 @@ describe('ReportPage — con datos completos', () => {
bpmnXml: '<?xml version="1.0"?><definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"><process id="p"><startEvent id="s"><outgoing>f1</outgoing></startEvent><task id="t1" name="Task 1"><incoming>f1</incoming><outgoing>f2</outgoing></task><endEvent id="e"><incoming>f2</incoming></endEvent><sequenceFlow id="f1" sourceRef="s" targetRef="t1"/><sequenceFlow id="f2" sourceRef="t1" targetRef="e"/></process></definitions>',
currency: 'USD',
overheadPercentage: 0.2,
annualFrequency: 1000,
analysisHorizonYears: 3,
automationInvestment: 0,
createdAt: Date.now() - 1000 * 60 * 30,
updatedAt: Date.now() - 1000 * 60 * 30,
}

View File

@@ -31,6 +31,7 @@ function buildSimInput(xml: string, directCost: number, overhead: number, curren
directCostFixed: directCost,
executionTimeMinutes: 30,
assignedResources: [],
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
}])
)

View File

@@ -0,0 +1,792 @@
/**
* Tests de los componentes de ROI del reporte (Sprint 1 — Etapa 3).
*
* Cubre: RoiKpiBar, ComparisonTable, TransparencySection, TopSavingsChart,
* CumulativeSavingsChart, y el comportamiento de tabs de ReportPage.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import React from 'react'
// ─── Mocks de dependencias del browser ───────────────────────────────────────
vi.mock('bpmn-js/lib/Viewer', () => ({
default: class MockViewer {
importXML = vi.fn().mockResolvedValue({ warnings: [] })
get = vi.fn().mockImplementation((svc: string) => {
if (svc === 'canvas') return { zoom: vi.fn(), addMarker: vi.fn(), removeMarker: vi.fn() }
if (svc === 'elementRegistry') return { forEach: vi.fn(), getGraphics: vi.fn().mockReturnValue(null), get: vi.fn().mockReturnValue({ width: 100 }) }
if (svc === 'overlays') return { add: vi.fn(), remove: vi.fn() }
if (svc === 'eventBus') return { on: vi.fn(), off: vi.fn() }
return {}
})
destroy = vi.fn()
},
}))
vi.mock('echarts-for-react', () => ({
default: ({ style }: { option: unknown; style?: React.CSSProperties }) => (
<div data-testid="echarts-chart" style={style} />
),
}))
vi.mock('@/features/report/useReportData', () => ({
useReportData: vi.fn(),
}))
vi.mock('@tanstack/react-router', async (importActual) => {
const actual = await importActual<typeof import('@tanstack/react-router')>()
return {
...actual,
useParams: vi.fn().mockReturnValue({ processId: 'test-proc-id' }),
useNavigate: vi.fn().mockReturnValue(vi.fn()),
}
})
// ─── Fixtures ─────────────────────────────────────────────────────────────────
import type { ActivitySimResult, Activity, SimulationResult } from '@/domain/types'
import type { RoiResult } from '@/domain/roi'
import { TooltipProvider } from '@/components/ui/tooltip'
const makeActivity = (id: string, name: string, cost: number): ActivitySimResult => ({
activityId: id,
bpmnElementId: `bpmn_${id}`,
activityName: name,
expectedDirectCost: cost * 0.83,
expectedIndirectCost: cost * 0.17,
expectedTotalCost: cost,
percentOfTotal: 33,
executionProbability: 1.0,
expectedExecutions: 1.0,
executionTimeMinutes: 60,
resourceCostBreakdown: [],
})
const perActivityActual: ActivitySimResult[] = [
makeActivity('a1', 'Recibir solicitud', 600),
makeActivity('a2', 'Calcular score', 300),
makeActivity('a3', 'Analizar riesgo', 100),
]
const perActivityAutomated: ActivitySimResult[] = [
makeActivity('a1', 'Recibir solicitud', 60), // 90% ahorro
makeActivity('a2', 'Calcular score', 30), // 90% ahorro
makeActivity('a3', 'Analizar riesgo', 100), // sin cambio (no automatable)
]
const mockResult: SimulationResult = {
totalCost: 1000,
totalDirectCost: 833,
totalIndirectCost: 167,
totalTimeMinutes: 180,
perActivity: perActivityActual,
perResource: [],
warnings: [],
}
const mockResultAutomated: SimulationResult = {
totalCost: 190,
totalDirectCost: 158,
totalIndirectCost: 32,
totalTimeMinutes: 30,
perActivity: perActivityAutomated,
perResource: [],
warnings: [],
}
const mockRoi: RoiResult = {
savingsPerExecution: 810,
savingsPerExecutionPercent: 81,
annualSavings: 9_720_000,
paybackMonths: 0.617,
paybackYears: 0.051,
cumulativeSavingsHorizon: 29_110_000,
breaksEvenInHorizon: true,
roiAnnualPercent: 19440,
}
const roiNoSavings: RoiResult = {
savingsPerExecution: -50,
savingsPerExecutionPercent: -10,
annualSavings: -60_000,
paybackMonths: Infinity,
paybackYears: Infinity,
cumulativeSavingsHorizon: -230_000,
breaksEvenInHorizon: false,
roiAnnualPercent: -60,
}
const roiZeroInvestment: RoiResult = {
savingsPerExecution: 200,
savingsPerExecutionPercent: 50,
annualSavings: 240_000,
paybackMonths: 0,
paybackYears: 0,
cumulativeSavingsHorizon: 720_000,
breaksEvenInHorizon: true,
roiAnnualPercent: Infinity,
}
// ─── RoiKpiBar ────────────────────────────────────────────────────────────────
import { RoiKpiBar } from '@/features/report/RoiKpiBar'
describe('RoiKpiBar', () => {
it('renderiza exactamente 5 ROI KPI cards', () => {
render(<RoiKpiBar roi={mockRoi} currency="USD" horizonYears={3} />)
expect(screen.getAllByTestId('roi-kpi-card')).toHaveLength(5)
})
it('muestra el ahorro por ejecución formateado con moneda', () => {
render(<RoiKpiBar roi={mockRoi} currency="USD" horizonYears={3} />)
const text = document.body.textContent ?? ''
// $810.00 debe aparecer
expect(text).toMatch(/810/)
})
it('payback Infinity → muestra "No se recupera"', () => {
render(<RoiKpiBar roi={roiNoSavings} currency="USD" horizonYears={3} />)
expect(screen.getByText(/No se recupera/i)).toBeInTheDocument()
})
it('payback 0 (inversión cero) → muestra "Inmediato"', () => {
render(<RoiKpiBar roi={roiZeroInvestment} currency="USD" horizonYears={3} />)
expect(screen.getByText(/Inmediato/i)).toBeInTheDocument()
})
it('ROI Infinity (inversión cero) → muestra "∞%"', () => {
render(<RoiKpiBar roi={roiZeroInvestment} currency="USD" horizonYears={3} />)
expect(screen.getByText('∞%')).toBeInTheDocument()
})
it('ahorro negativo: "breaksEvenInHorizon = false" → subtexto fuera del horizonte', () => {
render(<RoiKpiBar roi={roiNoSavings} currency="USD" horizonYears={3} />)
expect(screen.getByText(/Fuera del horizonte/i)).toBeInTheDocument()
})
it('breaksEvenInHorizon = true → subtexto dentro del horizonte', () => {
render(<RoiKpiBar roi={mockRoi} currency="USD" horizonYears={3} />)
expect(screen.getByText(/Dentro del horizonte/i)).toBeInTheDocument()
})
it('horizonYears se refleja en el label del KPI de acumulado', () => {
render(<RoiKpiBar roi={mockRoi} currency="USD" horizonYears={5} />)
const text = document.body.textContent ?? ''
expect(text).toMatch(/5 años/)
})
it('ningún KPI muestra NaN', () => {
render(<RoiKpiBar roi={mockRoi} currency="USD" horizonYears={3} />)
const text = document.body.textContent ?? ''
expect(text).not.toContain('NaN')
})
})
// ─── ComparisonTable ──────────────────────────────────────────────────────────
import { ComparisonTable } from '@/features/report/ComparisonTable'
const automatableIds = new Set(['bpmn_a1', 'bpmn_a2']) // a1 y a2 son automatizables
const noAutomatableIds = new Set<string>()
describe('ComparisonTable', () => {
it('renderiza N+1 rows (N actividades + 1 header)', () => {
render(
<ComparisonTable
perActivityActual={perActivityActual}
perActivityAutomated={perActivityAutomated}
automatableIds={automatableIds}
currency="USD"
/>
)
const rows = screen.getAllByRole('row')
expect(rows).toHaveLength(perActivityActual.length + 1)
})
it('actividades automatable muestran ícono Bot', () => {
render(
<ComparisonTable
perActivityActual={perActivityActual}
perActivityAutomated={perActivityAutomated}
automatableIds={automatableIds}
currency="USD"
/>
)
// a1 y a2 son automatable → 2 íconos Bot
const botIcons = document.querySelectorAll('[aria-label="Marcada como automatizable"]')
expect(botIcons).toHaveLength(2)
})
it('actividades NO automatable NO muestran ícono Bot', () => {
render(
<ComparisonTable
perActivityActual={perActivityActual}
perActivityAutomated={perActivityAutomated}
automatableIds={noAutomatableIds} // ninguna automatable
currency="USD"
/>
)
const botIcons = document.querySelectorAll('[aria-label="Marcada como automatizable"]')
expect(botIcons).toHaveLength(0)
})
it('ahorro de a1: 600 - 60 = 540 aparece en la tabla', () => {
render(
<ComparisonTable
perActivityActual={perActivityActual}
perActivityAutomated={perActivityAutomated}
automatableIds={automatableIds}
currency="USD"
/>
)
const text = document.body.textContent ?? ''
expect(text).toMatch(/540/)
})
it('tabla vacía: muestra mensaje sin actividades', () => {
render(
<ComparisonTable
perActivityActual={[]}
perActivityAutomated={[]}
automatableIds={noAutomatableIds}
currency="USD"
/>
)
expect(screen.getByText(/Sin actividades para comparar/i)).toBeInTheDocument()
})
it('ahorro negativo: se muestra sin prefijo +', () => {
const negativeActual = [makeActivity('x1', 'Tarea cara auto', 100)]
const negativeAuto = [makeActivity('x1', 'Tarea cara auto', 200)] // costo auto > actual
render(
<ComparisonTable
perActivityActual={negativeActual}
perActivityAutomated={negativeAuto}
automatableIds={new Set(['bpmn_x1'])}
currency="USD"
/>
)
// -100 de ahorro, sin prefijo '+'
const text = document.body.textContent ?? ''
expect(text).not.toMatch(/\+.*-/)
})
it('sort toggle: clic en encabezado "Ahorro" invierte el orden', () => {
render(
<ComparisonTable
perActivityActual={perActivityActual}
perActivityAutomated={perActivityAutomated}
automatableIds={automatableIds}
currency="USD"
/>
)
// Buscar el th que contiene "Ahorro" (hay múltiples elementos con ese texto)
const headers = screen.getAllByRole('columnheader')
const ahorroHeader = headers.find((h) => /Ahorro/i.test(h.textContent ?? ''))
expect(ahorroHeader).toBeDefined()
// Antes del click: orden desc (a1=540 primero)
const rowsBefore = screen.getAllByRole('row').slice(1)
const firstNameBefore = rowsBefore[0].textContent
fireEvent.click(ahorroHeader!)
// Después del click: orden asc (a3=0 primero)
const rowsAfter = screen.getAllByRole('row').slice(1)
const firstNameAfter = rowsAfter[0].textContent
expect(firstNameBefore).not.toBe(firstNameAfter)
})
})
// ─── TransparencySection ──────────────────────────────────────────────────────
import { TransparencySection } from '@/features/report/TransparencySection'
const makeActivityDomain = (id: string, name: string, automatable: boolean, zeroCosts: boolean): Activity => ({
id, processId: 'p1', bpmnElementId: `bpmn_${id}`, name, type: 'task',
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
automatable,
automatedCostFixed: zeroCosts ? 0 : 50,
automatedTimeMinutes: zeroCosts ? 0 : 10,
})
describe('TransparencySection', () => {
it('retorna null cuando no hay issues — no renderiza nada', () => {
const { container } = render(
<TransparencySection
activities={[makeActivityDomain('a1', 'Tarea', true, false)]} // automatable con costos configurados
investment={50_000}
/>
)
expect(container.firstChild).toBeNull()
})
it('Caso 1: actividad automatable con costos en cero → sección visible', () => {
render(
<TransparencySection
activities={[makeActivityDomain('a1', 'Facturación', true, true)]} // automatable, costo=0
investment={50_000}
/>
)
expect(screen.getByText(/Transparencia metodológica/i)).toBeInTheDocument()
expect(screen.getByText('Facturación')).toBeInTheDocument()
})
it('Caso 1: mensaje explica que el ahorro puede estar inflado', () => {
render(
<TransparencySection
activities={[makeActivityDomain('a1', 'Facturación', true, true)]}
investment={50_000}
/>
)
expect(screen.getByText(/inflar artificialmente el ahorro/i)).toBeInTheDocument()
})
it('Caso 1: plural correcto cuando hay múltiples actividades en cero', () => {
render(
<TransparencySection
activities={[
makeActivityDomain('a1', 'Tarea 1', true, true),
makeActivityDomain('a2', 'Tarea 2', true, true),
]}
investment={50_000}
/>
)
expect(screen.getByText(/2 actividades automatizables sin costo configurado/i)).toBeInTheDocument()
})
it('Caso 2: investment=0 → muestra aviso de inversión no configurada', () => {
render(
<TransparencySection
activities={[makeActivityDomain('a1', 'Tarea', true, false)]} // costos OK
investment={0}
/>
)
expect(screen.getByText(/Inversión en automatización no configurada/i)).toBeInTheDocument()
expect(screen.getByText(/payback aparece como inmediato/i)).toBeInTheDocument()
})
it('Caso 3: ambos issues coexisten — se muestran ambos mensajes en la misma sección', () => {
render(
<TransparencySection
activities={[makeActivityDomain('a1', 'Facturación', true, true)]} // costo cero
investment={0} // inversión cero
/>
)
expect(screen.getByText('Facturación')).toBeInTheDocument()
expect(screen.getByText(/Inversión en automatización no configurada/i)).toBeInTheDocument()
})
it('actividades no automatable no generan issue aunque tengan costos cero', () => {
const { container } = render(
<TransparencySection
activities={[makeActivityDomain('a1', 'Manual', false, true)]} // no automatable
investment={50_000}
/>
)
expect(container.firstChild).toBeNull()
})
// ─── Caso 4: ROI inusualmente alto ────────────────────────────────────────
const makeRoi = (roiAnnualPercent: number): RoiResult => ({
savingsPerExecution: 100,
savingsPerExecutionPercent: 50,
annualSavings: 1_200_000,
paybackMonths: 0.5,
paybackYears: 0.04,
cumulativeSavingsHorizon: 3_550_000,
breaksEvenInHorizon: true,
roiAnnualPercent,
})
it('Caso 4: ROI al 999% NO dispara el aviso de ROI inusual', () => {
const { container } = render(
<TransparencySection
activities={[makeActivityDomain('a1', 'T', true, false)]}
investment={50_000}
roi={makeRoi(999)}
/>
)
// No hay otros issues → el componente no renderiza (999% no supera el umbral)
expect(container.firstChild).toBeNull()
})
it('Caso 4: ROI al 1001% SÍ dispara el aviso de ROI inusual', () => {
render(
<TransparencySection
activities={[makeActivityDomain('a1', 'T', true, false)]}
investment={50_000}
roi={makeRoi(1001)}
/>
)
expect(screen.getByText(/ROI inusualmente alto detectado/i)).toBeInTheDocument()
// El texto incluye el valor formateado (>1000%)
const text = document.body.textContent ?? ''
expect(text).toMatch(/1\.001/)
})
it('Caso 4: ROI Infinity (investment=0) NO dispara este aviso — lo cubre el caso 2', () => {
const { container } = render(
<TransparencySection
activities={[makeActivityDomain('a1', 'T', true, false)]}
investment={0} // caso 2 activo
roi={makeRoi(Infinity)}
/>
)
// El caso 2 (investment=0) sí aparece, pero NO el aviso de "ROI inusualmente alto"
expect(screen.queryByText(/ROI inusualmente alto detectado/i)).not.toBeInTheDocument()
// El caso 2 sí está presente
expect(container.firstChild).not.toBeNull()
expect(screen.getByText(/Inversión en automatización no configurada/i)).toBeInTheDocument()
})
it('Caso 4: ROI negativo NO dispara el aviso de ROI inusual', () => {
const { container } = render(
<TransparencySection
activities={[makeActivityDomain('a1', 'T', true, false)]}
investment={50_000}
roi={makeRoi(-50)}
/>
)
expect(container.firstChild).toBeNull()
expect(screen.queryByText(/ROI inusualmente alto detectado/i)).not.toBeInTheDocument()
})
it('Caso 4 coexiste con Caso 1: ROI alto Y actividad con costo=0 → ambos mensajes', () => {
render(
<TransparencySection
activities={[makeActivityDomain('a1', 'Facturación', true, true)]} // costo=0
investment={50_000} // no es cero
roi={makeRoi(5000)} // > 1000 y finite
/>
)
expect(screen.getByText('Facturación')).toBeInTheDocument() // Caso 1
expect(screen.getByText(/ROI inusualmente alto detectado/i)).toBeInTheDocument() // Caso 4
})
})
// ─── TopSavingsChart ──────────────────────────────────────────────────────────
import { TopSavingsChart } from '@/features/report/TopSavingsChart'
describe('TopSavingsChart', () => {
it('muestra empty state cuando no hay ahorros positivos', () => {
// perActivityAutomated con costos mayores → ahorro negativo
const actualCosts = [makeActivity('a1', 'Tarea', 100)]
const autoCosts = [makeActivity('a1', 'Tarea', 200)]
render(
<TopSavingsChart
perActivityActual={actualCosts}
perActivityAutomated={autoCosts}
currency="USD"
/>
)
expect(screen.getByText(/Sin actividades con ahorro positivo/i)).toBeInTheDocument()
})
it('con ahorros positivos: renderiza el chart ECharts', () => {
render(
<TopSavingsChart
perActivityActual={perActivityActual}
perActivityAutomated={perActivityAutomated}
currency="USD"
/>
)
expect(screen.getByTestId('echarts-chart')).toBeInTheDocument()
})
it('excluye actividades con ahorro cero o negativo del chart', () => {
// a3 no tiene ahorro (costos iguales) → no debe aparecer en el chart
// Solo a1 y a2 tienen ahorro positivo
// Si renderiza chart → hay ≥1 actividad con ahorro > 0
render(
<TopSavingsChart
perActivityActual={perActivityActual}
perActivityAutomated={perActivityAutomated}
currency="USD"
/>
)
// Chart renderizado → hay actividades con ahorro positivo
expect(screen.getByTestId('echarts-chart')).toBeInTheDocument()
})
it('con más de 5 actividades: sigue renderizando chart (top-5 filtrado internamente)', () => {
const manyActual = Array.from({ length: 8 }, (_, i) =>
makeActivity(`a${i}`, `Tarea ${i}`, (i + 1) * 100)
)
const manyAuto = Array.from({ length: 8 }, (_, i) =>
makeActivity(`a${i}`, `Tarea ${i}`, (i + 1) * 10)
)
render(
<TopSavingsChart
perActivityActual={manyActual}
perActivityAutomated={manyAuto}
currency="USD"
/>
)
// No debe tirar error ni mostrar más de 5 en el chart (ECharts mockeado — no podemos inspeccionar option)
expect(screen.getByTestId('echarts-chart')).toBeInTheDocument()
})
})
// ─── CumulativeSavingsChart ───────────────────────────────────────────────────
import { CumulativeSavingsChart } from '@/features/report/CumulativeSavingsChart'
describe('CumulativeSavingsChart', () => {
it('renderiza el chart sin crash (payback dentro del horizonte)', () => {
render(
<CumulativeSavingsChart
roi={mockRoi}
horizonYears={3}
currency="USD"
investment={50_000}
/>
)
expect(screen.getByTestId('echarts-chart')).toBeInTheDocument()
})
it('renderiza el chart sin crash cuando payback = Infinity (sin ahorro)', () => {
render(
<CumulativeSavingsChart
roi={roiNoSavings}
horizonYears={3}
currency="USD"
investment={50_000}
/>
)
expect(screen.getByTestId('echarts-chart')).toBeInTheDocument()
})
it('renderiza el chart sin crash cuando investment = 0', () => {
render(
<CumulativeSavingsChart
roi={roiZeroInvestment}
horizonYears={3}
currency="USD"
investment={0}
/>
)
expect(screen.getByTestId('echarts-chart')).toBeInTheDocument()
})
})
// ─── ReportPage — comportamiento de tabs ──────────────────────────────────────
import { useReportData } from '@/features/report/useReportData'
import { ReportPage } from '@/features/report/ReportPage'
const mockProcessBase = {
id: 'test-proc-id', name: 'Proceso Test', clientName: 'Cliente',
bpmnXml: '<?xml version="1.0"?><definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"><process id="p"><startEvent id="s"><outgoing>f1</outgoing></startEvent><task id="t1" name="Task 1"><incoming>f1</incoming><outgoing>f2</outgoing></task><endEvent id="e"><incoming>f2</incoming></endEvent><sequenceFlow id="f1" sourceRef="s" targetRef="t1"/><sequenceFlow id="f2" sourceRef="t1" targetRef="e"/></process></definitions>',
currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
createdAt: Date.now() - 3600_000, updatedAt: Date.now() - 3600_000,
}
const mockSimulationBase = {
id: 'sim-1', processId: 'test-proc-id',
executedAt: Date.now() - 600_000,
result: mockResult,
}
const automtableActivity: Activity = {
id: 'act-1', processId: 'test-proc-id', bpmnElementId: 'bpmn_a1', name: 'Recibir solicitud',
type: 'task', directCostFixed: 500, executionTimeMinutes: 60, assignedResources: [],
automatable: true, automatedCostFixed: 50, automatedTimeMinutes: 5,
}
const nonAutomatableActivity: Activity = {
...automtableActivity, id: 'act-2', bpmnElementId: 'bpmn_a2', name: 'Procesar',
automatable: false,
}
describe('ReportPage — comportamiento de tabs', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('sin resultAutomated (snapshot viejo): tabs "Automatizado" y "ROI" están disabled', () => {
vi.mocked(useReportData).mockReturnValue({
process: mockProcessBase,
simulation: { ...mockSimulationBase }, // sin resultAutomated
activities: [automtableActivity],
resources: [],
isLoading: false,
error: null,
})
render(
<TooltipProvider>
<ReportPage />
</TooltipProvider>
)
const tabAuto = screen.getByRole('tab', { name: /automatizado/i })
const tabRoi = screen.getByRole('tab', { name: /comparación/i })
expect(tabAuto).toBeDisabled()
expect(tabRoi).toBeDisabled()
})
it('con resultAutomated pero SIN actividades automatizables: tabs disabled', () => {
vi.mocked(useReportData).mockReturnValue({
process: mockProcessBase,
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
activities: [nonAutomatableActivity], // no hay automatable
resources: [],
isLoading: false,
error: null,
})
render(
<TooltipProvider>
<ReportPage />
</TooltipProvider>
)
const tabAuto = screen.getByRole('tab', { name: /automatizado/i })
const tabRoi = screen.getByRole('tab', { name: /comparación/i })
expect(tabAuto).toBeDisabled()
expect(tabRoi).toBeDisabled()
})
it('con resultAutomated Y actividades automatizables: tabs habilitados', () => {
vi.mocked(useReportData).mockReturnValue({
process: mockProcessBase,
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
activities: [automtableActivity], // hay 1 automatable
resources: [],
isLoading: false,
error: null,
})
render(
<TooltipProvider>
<ReportPage />
</TooltipProvider>
)
const tabAuto = screen.getByRole('tab', { name: /automatizado/i })
const tabRoi = screen.getByRole('tab', { name: /comparación/i })
expect(tabAuto).not.toBeDisabled()
expect(tabRoi).not.toBeDisabled()
})
it('tab "Actual" activo por defecto: muestra los 4 KPI cards del costo actual', () => {
vi.mocked(useReportData).mockReturnValue({
process: mockProcessBase,
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
activities: [automtableActivity],
resources: [],
isLoading: false,
error: null,
})
render(
<TooltipProvider>
<ReportPage />
</TooltipProvider>
)
// Tab "Actual" activo por defecto → 4 kpi-cards (no 5 roi-kpi-cards)
expect(screen.getAllByTestId('kpi-card')).toHaveLength(4)
expect(screen.queryByTestId('roi-kpi-card')).toBeNull()
})
it('click en tab "Comparación + ROI": muestra 5 ROI KPI cards', async () => {
const user = userEvent.setup()
vi.mocked(useReportData).mockReturnValue({
process: mockProcessBase,
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
activities: [automtableActivity],
resources: [],
isLoading: false,
error: null,
})
render(
<TooltipProvider>
<ReportPage />
</TooltipProvider>
)
await user.click(screen.getByRole('tab', { name: /comparación/i }))
await waitFor(() => expect(screen.getAllByTestId('roi-kpi-card')).toHaveLength(5))
})
it('click en tab "Comparación + ROI": la tabla de comparación tiene N+1 rows', async () => {
const user = userEvent.setup()
vi.mocked(useReportData).mockReturnValue({
process: mockProcessBase,
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
activities: [automtableActivity],
resources: [],
isLoading: false,
error: null,
})
render(
<TooltipProvider>
<ReportPage />
</TooltipProvider>
)
await user.click(screen.getByRole('tab', { name: /comparación/i }))
// mockResult tiene 3 actividades → 3 data rows + 1 header
await waitFor(() => expect(screen.getAllByRole('row')).toHaveLength(perActivityActual.length + 1))
})
it('click en tab "Comparación + ROI" con investment=0: TransparencySection visible', async () => {
const user = userEvent.setup()
vi.mocked(useReportData).mockReturnValue({
process: { ...mockProcessBase, automationInvestment: 0 },
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
activities: [automtableActivity],
resources: [],
isLoading: false,
error: null,
})
render(
<TooltipProvider>
<ReportPage />
</TooltipProvider>
)
await user.click(screen.getByRole('tab', { name: /comparación/i }))
await waitFor(() => expect(screen.getByText(/Transparencia metodológica/i)).toBeInTheDocument())
})
it('tab "Automatizado": muestra los 4 KPI cards del escenario automatizado', () => {
vi.mocked(useReportData).mockReturnValue({
process: mockProcessBase,
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
activities: [automtableActivity],
resources: [],
isLoading: false,
error: null,
})
render(
<TooltipProvider>
<ReportPage />
</TooltipProvider>
)
fireEvent.click(screen.getByRole('tab', { name: /automatizado/i }))
// El tab automatizado también muestra 4 kpi-cards (del escenario automatizado)
const kpiCards = screen.getAllByTestId('kpi-card')
// Puede haber 4 del tab actual (ocultos) + 4 del tab automatizado = 8
// O solo 4 si Radix desmonta el tab inactivo
expect(kpiCards.length).toBeGreaterThanOrEqual(4)
})
it('tab "Automatizado": la nota de AutomationScenarioNote está presente', async () => {
const user = userEvent.setup()
vi.mocked(useReportData).mockReturnValue({
process: mockProcessBase,
simulation: { ...mockSimulationBase, resultAutomated: mockResultAutomated },
activities: [automtableActivity],
resources: [],
isLoading: false,
error: null,
})
render(
<TooltipProvider>
<ReportPage />
</TooltipProvider>
)
await user.click(screen.getByRole('tab', { name: /automatizado/i }))
await waitFor(() =>
expect(screen.getByText(/actividades no marcadas como automatizables/i)).toBeInTheDocument()
)
})
})

View File

@@ -0,0 +1,157 @@
/**
* Tests de la sección "Automatización" del ActivityPanel.
*
* Los mocks del store usan vi.hoisted() para crear referencias estables:
* si el mock devuelve una nueva referencia de activities en cada render,
* el useEffect de ActivityPanel entra en loop infinito.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { TooltipProvider } from '@/components/ui/tooltip'
// ─── Mocks con referencias estables vía vi.hoisted ───────────────────────────
const processStoreMock = vi.hoisted(() => {
const activity = {
id: 'act-1', processId: 'p1', bpmnElementId: 'task1', name: 'Facturación',
type: 'task' as const,
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
}
return {
activities: [activity], // referencia estable — no cambia entre renders
resources: [],
updateActivity: vi.fn(),
subscribe: vi.fn(), // requerido por simulation-store.ts al importarse
}
})
const simulationStoreMock = vi.hoisted(() => ({
resultActual: null as null | object,
resultAutomated: null as null | object,
invalidateResults: vi.fn(),
}))
vi.mock('@/store/process-store', () => ({
useProcessStore: () => processStoreMock,
// Zustand también expone .subscribe() como método estático del hook
useProcessStore_subscribe: vi.fn(),
}))
vi.mock('@/store/simulation-store', () => ({
useSimulationStore: () => simulationStoreMock,
}))
import { ActivityPanel } from '@/features/workspace/ActivityPanel'
// ─── Helper ───────────────────────────────────────────────────────────────────
function renderPanel(selectedElementId = 'task1') {
return render(
<TooltipProvider>
<ActivityPanel selectedElementId={selectedElementId} />
</TooltipProvider>
)
}
// ─── Tests ────────────────────────────────────────────────────────────────────
beforeEach(() => {
// Resetear el mock de updateActivity y restaurar la actividad base
processStoreMock.updateActivity.mockReset()
processStoreMock.updateActivity.mockResolvedValue(undefined)
processStoreMock.activities[0].automatable = false
processStoreMock.activities[0].automatedCostFixed = 0
processStoreMock.activities[0].automatedTimeMinutes = 0
simulationStoreMock.resultActual = null
simulationStoreMock.resultAutomated = null
simulationStoreMock.invalidateResults.mockReset()
})
describe('ActivityPanel — sección Automatización', () => {
it('sección Automatización está presente y toggle en OFF por defecto', () => {
renderPanel()
expect(screen.getByText('¿Es automatizable?')).toBeInTheDocument()
const toggle = screen.getByRole('switch', { name: /automatizable/i })
expect(toggle).toHaveAttribute('aria-checked', 'false')
})
it('Toggle ON: aria-checked cambia a true', async () => {
renderPanel()
const toggle = screen.getByRole('switch', { name: /automatizable/i })
await act(async () => { fireEvent.click(toggle) })
expect(toggle).toHaveAttribute('aria-checked', 'true')
})
it('Toggle ON con ambos campos en 0: helper ámbar está en el DOM y visible (opacity-100)', async () => {
renderPanel()
const toggle = screen.getByRole('switch', { name: /automatizable/i })
await act(async () => { fireEvent.click(toggle) })
// El helper siempre está en el DOM — verificamos que el texto existe y el contenedor es visible
const helperText = screen.getByText(/Transparencia metodológica/i)
expect(helperText).toBeInTheDocument()
// El contenedor padre tiene opacity-100 cuando showHelper=true
const helperContainer = helperText.closest('[aria-hidden]')
expect(helperContainer).toHaveAttribute('aria-hidden', 'false')
})
it('Toggle ON con costo > 0: helper ámbar está en el DOM pero oculto (aria-hidden=true)', async () => {
renderPanel()
const toggle = screen.getByRole('switch', { name: /automatizable/i })
await act(async () => { fireEvent.click(toggle) })
const costoInput = screen.getByLabelText(/Costo automatizado/i)
await act(async () => {
fireEvent.change(costoInput, { target: { value: '50' } })
})
// El texto sigue en el DOM (transición CSS, no unmount)
const helperText = screen.getByText(/Transparencia metodológica/i)
expect(helperText).toBeInTheDocument()
// Pero el contenedor está marcado aria-hidden=true (oculto visualmente)
const helperContainer = helperText.closest('[aria-hidden]')
expect(helperContainer).toHaveAttribute('aria-hidden', 'true')
})
it('Editar costo automatizado y guardar llama updateActivity con el valor correcto', async () => {
renderPanel()
const toggle = screen.getByRole('switch', { name: /automatizable/i })
await act(async () => { fireEvent.click(toggle) })
const costoInput = screen.getByLabelText(/Costo automatizado/i)
await act(async () => {
fireEvent.change(costoInput, { target: { value: '25' } })
})
const guardarBtn = screen.getByRole('button', { name: /Guardar/i })
await act(async () => { fireEvent.click(guardarBtn) })
expect(processStoreMock.updateActivity).toHaveBeenCalledOnce()
const arg = processStoreMock.updateActivity.mock.calls[0][0]
expect(arg.automatedCostFixed).toBe(25)
expect(arg.automatable).toBe(true)
})
it('Guardar llama updateActivity (la invalidación reactiva es responsabilidad del store)', async () => {
renderPanel()
const toggle = screen.getByRole('switch', { name: /automatizable/i })
await act(async () => { fireEvent.click(toggle) })
const tiempoInput = screen.getByLabelText(/Tiempo automatizado/i)
await act(async () => {
fireEvent.change(tiempoInput, { target: { value: '5' } })
})
const guardarBtn = screen.getByRole('button', { name: /Guardar/i })
await act(async () => { fireEvent.click(guardarBtn) })
expect(processStoreMock.updateActivity).toHaveBeenCalledOnce()
const arg = processStoreMock.updateActivity.mock.calls[0][0]
expect(arg.automatedTimeMinutes).toBe(5)
})
})

View File

@@ -0,0 +1,131 @@
/**
* Tests del overlay de automatización en BpmnCanvas.
*
* bpmn-js se mockea completamente. El elementRegistry.get() devuelve
* elementos con width=100 para verificar el posicionamiento left=width-12=88.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, act } from '@testing-library/react'
// ─── Mock de bpmn-js ──────────────────────────────────────────────────────────
const mockOverlaysAdd = vi.fn()
const mockOverlaysRemove = vi.fn()
const mockCanvasZoom = vi.fn()
const mockRegForEach = vi.fn()
// elementRegistry.get() devuelve un elemento con width=100 (nodo estándar de bpmn-js)
const mockRegGet = vi.fn().mockReturnValue({ width: 100, height: 80 })
vi.mock('bpmn-js/lib/Viewer', () => ({
default: class MockViewer {
importXML = vi.fn().mockResolvedValue({ warnings: [] })
get = vi.fn().mockImplementation((svc: string) => {
if (svc === 'overlays') return { add: mockOverlaysAdd, remove: mockOverlaysRemove }
if (svc === 'canvas') return { zoom: mockCanvasZoom, addMarker: vi.fn(), removeMarker: vi.fn() }
if (svc === 'elementRegistry') return { forEach: mockRegForEach, get: mockRegGet }
if (svc === 'eventBus') return { on: vi.fn(), off: vi.fn() }
return {}
})
destroy = vi.fn()
},
}))
import { BpmnCanvas } from '@/features/workspace/BpmnCanvas'
// ─── Fixture ──────────────────────────────────────────────────────────────────
const SIMPLE_XML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" targetNamespace="test">
<process id="p"><startEvent id="s"/></process>
</definitions>`
// Con width=100 de mockRegGet, left = 100 - 12 = 88
const EXPECTED_LEFT = 88
const EXPECTED_TOP = -8
beforeEach(() => {
vi.clearAllMocks()
mockRegGet.mockReturnValue({ width: 100, height: 80 })
})
async function flush() {
await act(async () => { await new Promise((r) => setTimeout(r, 0)) })
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('BpmnCanvas — overlay de automatización', () => {
it('sin actividades automatizables: overlays.add NO se llama, remove sí', async () => {
await act(async () => {
render(<BpmnCanvas xml={SIMPLE_XML} automatableElementIds={[]} />)
})
await flush()
expect(mockOverlaysAdd).not.toHaveBeenCalled()
expect(mockOverlaysRemove).toHaveBeenCalledWith({ type: 'automation-indicator' })
})
it('posición: usa elementRegistry para calcular left=width-12, top=-8 (esquina superior derecha)', async () => {
await act(async () => {
render(<BpmnCanvas xml={SIMPLE_XML} automatableElementIds={['task_a']} />)
})
await flush()
expect(mockOverlaysAdd).toHaveBeenCalledOnce()
const position = mockOverlaysAdd.mock.calls[0][2].position
// top: -8 — badge 8px por encima del borde superior del nodo
expect(position.top).toBe(EXPECTED_TOP)
// left: width-12 = 88 — badge en la región derecha, 8px fuera del borde derecho (20-12=8px sticking out)
expect(position.left).toBe(EXPECTED_LEFT)
// No usa right (semántica confusa en diagram-js: right:-8 → left=8+width, enteramente fuera)
expect(position.right).toBeUndefined()
})
it('con 2 actividades automatizables: overlays.add llamado 2 veces con posición correcta', async () => {
await act(async () => {
render(<BpmnCanvas xml={SIMPLE_XML} automatableElementIds={['task_a', 'task_b']} />)
})
await flush()
expect(mockOverlaysAdd).toHaveBeenCalledTimes(2)
for (const call of mockOverlaysAdd.mock.calls) {
expect(call[2].position).toEqual({ top: EXPECTED_TOP, left: EXPECTED_LEFT })
}
})
it('HTML del overlay: title correcto, SVG ámbar (#f59e0b), fondo #fffbeb', async () => {
await act(async () => {
render(<BpmnCanvas xml={SIMPLE_XML} automatableElementIds={['task_1']} />)
})
await flush()
const html: string = mockOverlaysAdd.mock.calls[0][2].html
expect(html).toContain('Actividad marcada como automatizable')
expect(html).toContain('<svg')
expect(html).toContain('#f59e0b') // color ámbar del ícono Bot
expect(html).toContain('#fffbeb') // fondo amber-50 del círculo
})
it('al quitar una actividad: remove() vuelve a llamarse y add() refleja la lista nueva', async () => {
const { rerender } = render(
<BpmnCanvas xml={SIMPLE_XML} automatableElementIds={['task_a', 'task_b']} />
)
await flush()
expect(mockOverlaysAdd).toHaveBeenCalledTimes(2)
const removeCalls1 = mockOverlaysRemove.mock.calls.length
await act(async () => {
rerender(<BpmnCanvas xml={SIMPLE_XML} automatableElementIds={['task_a']} />)
})
await flush()
expect(mockOverlaysRemove.mock.calls.length).toBeGreaterThan(removeCalls1)
expect(mockOverlaysAdd).toHaveBeenCalledTimes(3) // 2 + 1
})
})

View File

@@ -0,0 +1,132 @@
/**
* Tests de la sección "Análisis de Impacto" del GlobalSettingsPanel.
*
* Los mocks usan vi.hoisted() para que currentProcess sea una referencia
* estable entre renders (evita el loop infinito del useEffect de sync).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { TooltipProvider } from '@/components/ui/tooltip'
// ─── Mocks con referencia estable ─────────────────────────────────────────────
const processStoreMock = vi.hoisted(() => ({
currentProcess: {
id: 'p1', name: 'Proceso test', clientName: 'Cliente A',
bpmnXml: '<def/>', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0,
},
updateProcess: vi.fn(),
subscribe: vi.fn(),
}))
vi.mock('@/store/process-store', () => ({
useProcessStore: () => processStoreMock,
}))
vi.mock('@/store/simulation-store', () => ({
useSimulationStore: () => ({
resultActual: null,
resultAutomated: null,
invalidateResults: vi.fn(),
}),
}))
import { GlobalSettingsPanel } from '@/features/workspace/GlobalSettingsPanel'
// ─── Helper ───────────────────────────────────────────────────────────────────
function renderPanel() {
return render(
<TooltipProvider>
<GlobalSettingsPanel />
</TooltipProvider>
)
}
// ─── Tests ────────────────────────────────────────────────────────────────────
beforeEach(() => {
processStoreMock.updateProcess.mockReset()
processStoreMock.updateProcess.mockResolvedValue(undefined)
// Restaurar defaults del proceso
processStoreMock.currentProcess.annualFrequency = 1000
processStoreMock.currentProcess.analysisHorizonYears = 3
processStoreMock.currentProcess.automationInvestment = 0
})
describe('GlobalSettingsPanel — sección Análisis de Impacto', () => {
it('renderiza los 3 campos de impacto con sus labels correctos', () => {
renderPanel()
expect(screen.getByLabelText(/Frecuencia anual/i)).toBeInTheDocument()
expect(screen.getByText(/Horizonte de análisis/i)).toBeInTheDocument()
expect(screen.getByLabelText(/Inversión en automatización/i)).toBeInTheDocument()
})
it('frecuencia anual muestra el default 1000 del proceso', () => {
renderPanel()
const freqInput = screen.getByLabelText(/Frecuencia anual/i) as HTMLInputElement
expect(freqInput.value).toBe('1000')
})
it('editar frecuencia y guardar incluye el nuevo valor en updateProcess', async () => {
renderPanel()
const freqInput = screen.getByLabelText(/Frecuencia anual/i)
await act(async () => {
fireEvent.change(freqInput, { target: { value: '5000' } })
})
// Después de cambiar, el botón debería mostrar "Guardar configuración"
const guardarBtn = await screen.findByRole('button', { name: /Guardar/i })
await act(async () => { fireEvent.click(guardarBtn) })
expect(processStoreMock.updateProcess).toHaveBeenCalledOnce()
const calledWith = processStoreMock.updateProcess.mock.calls[0][0]
expect(calledWith.annualFrequency).toBe(5000)
})
it('horizonte de análisis se muestra en texto (default 3 años)', () => {
renderPanel()
expect(screen.getByText('3 años')).toBeInTheDocument()
})
it('guardar incluye horizonte actual en updateProcess', async () => {
renderPanel()
const freqInput = screen.getByLabelText(/Frecuencia anual/i)
await act(async () => {
fireEvent.change(freqInput, { target: { value: '1200' } })
})
const guardarBtn = await screen.findByRole('button', { name: /Guardar/i })
await act(async () => { fireEvent.click(guardarBtn) })
const calledWith = processStoreMock.updateProcess.mock.calls[0][0]
expect(calledWith.analysisHorizonYears).toBe(3)
expect(calledWith.analysisHorizonYears).toBeGreaterThanOrEqual(1)
expect(calledWith.analysisHorizonYears).toBeLessThanOrEqual(10)
})
it('editar inversión en automatización y guardar la incluye correctamente', async () => {
renderPanel()
const investInput = screen.getByLabelText(/Inversión en automatización/i)
await act(async () => {
fireEvent.change(investInput, { target: { value: '75000' } })
})
const guardarBtn = await screen.findByRole('button', { name: /Guardar/i })
await act(async () => { fireEvent.click(guardarBtn) })
const calledWith = processStoreMock.updateProcess.mock.calls[0][0]
expect(calledWith.automationInvestment).toBe(75000)
})
it('sección "ANÁLISIS DE IMPACTO" aparece con su label de sección', () => {
renderPanel()
expect(screen.getByText(/Análisis de Impacto/i)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,145 @@
/**
* Tests de migración Dexie v1 → v2 (Sprint 1).
*
* Verifican que:
* 1. Registros sin los campos nuevos reciben defaults correctos tras el upgrade.
* 2. Registros con los campos ya presentes no son modificados.
* 3. Los nuevos campos se pueden leer y persistir correctamente.
*
* Usan fake-indexeddb (parcheado en tests/setup.ts).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { db } from '@/persistence/db'
import { activityRepo, processRepo } from '@/persistence/repositories'
import type { Activity, Process } from '@/domain/types'
// ─── Fixtures de registros "viejos" (sin campos Sprint 1) ─────────────────────
function makeLegacyActivity(id: string, processId: string): Omit<Activity, 'automatable' | 'automatedCostFixed' | 'automatedTimeMinutes'> {
return {
id, processId,
bpmnElementId: `task_${id}`,
name: `Tarea ${id}`,
type: 'task',
directCostFixed: 500,
executionTimeMinutes: 45,
assignedResources: [],
}
}
function makeLegacyProcess(id: string): Omit<Process, 'annualFrequency' | 'analysisHorizonYears' | 'automationInvestment'> {
return {
id, name: 'Proceso Legacy', clientName: 'Cliente Viejo',
bpmnXml: '<definitions/>', currency: 'USD', overheadPercentage: 0.15,
createdAt: 1000, updatedAt: 2000,
}
}
// ─── Setup / Teardown ─────────────────────────────────────────────────────────
beforeEach(async () => {
await db.open()
await db.transaction('rw', [db.processes, db.activities], async () => {
await db.processes.clear()
await db.activities.clear()
})
})
afterEach(async () => {
await db.close()
})
// ─── Tests de defaults tras migración ─────────────────────────────────────────
describe('Migración Dexie v2 — Activity: defaults de campos Sprint 1', () => {
it('actividades nuevas tienen automatable=false por defecto', async () => {
const act: Activity = {
...makeLegacyActivity('a1', 'p1'),
automatable: false,
automatedCostFixed: 0,
automatedTimeMinutes: 0,
}
await activityRepo.save(act)
const retrieved = await activityRepo.getByProcess('p1')
expect(retrieved[0].automatable).toBe(false)
expect(retrieved[0].automatedCostFixed).toBe(0)
expect(retrieved[0].automatedTimeMinutes).toBe(0)
})
it('los campos de automatización persisten con valores no-default', async () => {
const act: Activity = {
...makeLegacyActivity('a2', 'p1'),
automatable: true,
automatedCostFixed: 12.5,
automatedTimeMinutes: 3,
}
await activityRepo.save(act)
const [retrieved] = await activityRepo.getByProcess('p1')
expect(retrieved.automatable).toBe(true)
expect(retrieved.automatedCostFixed).toBe(12.5)
expect(retrieved.automatedTimeMinutes).toBe(3)
})
it('update de actividad preserva los campos de automatización existentes', async () => {
const act: Activity = {
...makeLegacyActivity('a3', 'p1'),
automatable: true,
automatedCostFixed: 5,
automatedTimeMinutes: 10,
}
await activityRepo.save(act)
// Actualizar solo directCostFixed — los campos de automatización no deben cambiar
await activityRepo.save({ ...act, directCostFixed: 999 })
const [updated] = await activityRepo.getByProcess('p1')
expect(updated.directCostFixed).toBe(999)
expect(updated.automatable).toBe(true)
expect(updated.automatedCostFixed).toBe(5)
})
})
describe('Migración Dexie v2 — Process: defaults de campos Sprint 1', () => {
it('procesos nuevos tienen annualFrequency=1000 por defecto', async () => {
const proc: Process = {
...makeLegacyProcess('proc-1'),
annualFrequency: 1000,
analysisHorizonYears: 3,
automationInvestment: 0,
}
await processRepo.save(proc)
const retrieved = await processRepo.getById('proc-1')
expect(retrieved!.annualFrequency).toBe(1000)
expect(retrieved!.analysisHorizonYears).toBe(3)
expect(retrieved!.automationInvestment).toBe(0)
})
it('los campos de volumetría persisten con valores configurados', async () => {
const proc: Process = {
...makeLegacyProcess('proc-2'),
annualFrequency: 5000,
analysisHorizonYears: 5,
automationInvestment: 75_000,
}
await processRepo.save(proc)
const retrieved = await processRepo.getById('proc-2')
expect(retrieved!.annualFrequency).toBe(5000)
expect(retrieved!.analysisHorizonYears).toBe(5)
expect(retrieved!.automationInvestment).toBe(75_000)
})
it('update de proceso preserva campos de volumetría', async () => {
const proc: Process = {
...makeLegacyProcess('proc-3'),
annualFrequency: 2000,
analysisHorizonYears: 4,
automationInvestment: 30_000,
}
await processRepo.save(proc)
// Actualizar solo el nombre — los campos de volumetría no deben cambiar
await processRepo.save({ ...proc, name: 'Nombre Actualizado' })
const updated = await processRepo.getById('proc-3')
expect(updated!.name).toBe('Nombre Actualizado')
expect(updated!.annualFrequency).toBe(2000)
expect(updated!.automationInvestment).toBe(30_000)
})
})

View File

@@ -85,6 +85,7 @@ describe('motor / propagación con GatewayConfig correcta', () => {
actIds.map((id) => [id, {
id: uuidv4(), processId, bpmnElementId: id, name: id,
type: 'task', directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
}])
)

View File

@@ -27,6 +27,9 @@ function makeProcess(id = 'proc-1'): Process {
bpmnXml: '<definitions/>',
currency: 'USD',
overheadPercentage: 0.2,
annualFrequency: 1000,
analysisHorizonYears: 3,
automationInvestment: 0,
createdAt: 1000,
updatedAt: 2000,
}
@@ -42,6 +45,9 @@ function makeActivity(id: string, processId: string, bpmnElementId: string): Act
directCostFixed: 100,
executionTimeMinutes: 30,
assignedResources: [{ resourceId: 'res-1', utilizationPercent: 0.5 }],
automatable: false,
automatedCostFixed: 0,
automatedTimeMinutes: 0,
}
}

View File

@@ -53,6 +53,9 @@ function buildInput(
directCostFixed,
executionTimeMinutes,
assignedResources: [],
automatable: false,
automatedCostFixed: 0,
automatedTimeMinutes: 0,
},
])
)

View File

@@ -0,0 +1,232 @@
/**
* Test integrador del flujo completo del Sprint 1.
*
* Verifica que las piezas del dominio conectan correctamente:
* parseBpmnXml → extractActivityElements → runSimulation (actual + automated)
* → calculateRoi → KPIs correctos
*
* BPMN base: medium-with-gateways (Aprobación de crédito)
* — 11 actividades, 3 gateways (2 XOR + 1 AND), 5 flujos condicionales
*
* Costos usados (deliberadamente simples para assertions deterministas):
* - Todas las actividades: directCostFixed=$200, executionTimeMinutes=60
* - Automatable: solo task_recv y task_score (las más caras por prob alta)
* - automatedCostFixed=$20 (90% de ahorro), automatedTimeMinutes=5
* - annualFrequency=1200 (100 ejecuciones/mes), horizonte=3 años, inversión=$50.000
*/
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { v4 as uuidv4 } from 'uuid'
import { parseBpmnXml, extractActivityElements, extractGatewayElements } from '@/domain/bpmn-parser'
import { runSimulation } from '@/domain/simulation'
import { calculateRoi } from '@/domain/roi'
import { bpmnTypeToGatewayType } from '@/domain/types'
import type { Activity, GatewayConfig, SimulationInput } from '@/domain/types'
const MEDIUM_XML = readFileSync(
resolve(__dirname, '../../public/sample-processes/medium-with-gateways.bpmn'),
'utf-8'
)
// Actividades automatable y sus costos automatizados
const AUTOMATABLE_IDS = new Set(['task_recv', 'task_score'])
const AUTOMATED_COST = 20
const AUTOMATED_TIME = 5
function buildActivities(xml: string): Map<string, Activity> {
const graph = parseBpmnXml(xml)
const elements = extractActivityElements(graph)
return new Map(
elements.map((el) => [el.bpmnElementId, {
id: uuidv4(),
processId: 'sprint1-test',
bpmnElementId: el.bpmnElementId,
name: el.name,
type: el.type,
directCostFixed: 200,
executionTimeMinutes: 60,
assignedResources: [],
automatable: AUTOMATABLE_IDS.has(el.bpmnElementId),
automatedCostFixed: AUTOMATABLE_IDS.has(el.bpmnElementId) ? AUTOMATED_COST : 0,
automatedTimeMinutes: AUTOMATABLE_IDS.has(el.bpmnElementId) ? AUTOMATED_TIME : 0,
}])
)
}
function buildGateways(xml: string): Map<string, GatewayConfig> {
const graph = parseBpmnXml(xml)
const elements = extractGatewayElements(graph)
return new Map(
elements.map((el) => {
const gwType = bpmnTypeToGatewayType(el.gatewayType as any)
const n = el.outgoing.length
return [el.bpmnElementId, {
id: uuidv4(),
processId: 'sprint1-test',
bpmnElementId: el.bpmnElementId,
gatewayType: gwType,
branches: el.outgoing.map((flowId) => ({
flowId,
targetElementId: graph.flows.get(flowId)?.targetRef ?? '',
probability: gwType === 'parallel' ? 1.0 : parseFloat((1 / n).toFixed(4)),
})),
}]
})
)
}
const BASE_INPUT: Omit<SimulationInput, 'scenario'> = {
processXml: MEDIUM_XML,
activities: buildActivities(MEDIUM_XML),
gateways: buildGateways(MEDIUM_XML),
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0.2 },
}
const VOLUMETRY = {
annualFrequency: 1200,
analysisHorizonYears: 3,
automationInvestment: 50_000,
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('Sprint 1 — flujo integrado: simulación dual + ROI', () => {
const resultActual = runSimulation({ ...BASE_INPUT, scenario: 'actual' })
const resultAutomated = runSimulation({ ...BASE_INPUT, scenario: 'automated' })
// ─── Simulación actual ──────────────────────────────────────────────────
it('simulación actual produce resultado no nulo con actividades reales', () => {
expect(resultActual.totalCost).toBeGreaterThan(0)
expect(resultActual.perActivity.length).toBeGreaterThan(0)
expect(resultActual.warnings).toBeDefined()
})
it('simulación actual: task_recv tiene probabilidad 1.0 (primer nodo del proceso)', () => {
const recv = resultActual.perActivity.find((a) => a.bpmnElementId === 'task_recv')
expect(recv).toBeDefined()
expect(recv!.executionProbability).toBeCloseTo(1.0)
})
it('simulación actual: las actividades con costos tienen expectedDirectCost > 0', () => {
for (const act of resultActual.perActivity) {
expect(Number.isNaN(act.expectedDirectCost)).toBe(false)
expect(act.expectedDirectCost).toBeGreaterThanOrEqual(0)
}
})
// ─── Simulación automatizada ────────────────────────────────────────────
it('simulación automatizada produce totalCost menor que actual', () => {
// Hay actividades automatable con costo menor → total debe bajar
expect(resultAutomated.totalCost).toBeLessThan(resultActual.totalCost)
})
it('probabilidades de ejecución son idénticas entre escenarios', () => {
for (const actActual of resultActual.perActivity) {
const actAuto = resultAutomated.perActivity.find(
(a) => a.bpmnElementId === actActual.bpmnElementId
)
expect(actAuto).toBeDefined()
expect(actAuto!.executionProbability).toBeCloseTo(actActual.executionProbability, 5)
}
})
it('actividades automatable tienen expectedDirectCost menor en escenario automatizado', () => {
for (const bpmnElementId of AUTOMATABLE_IDS) {
const actual = resultActual.perActivity.find((a) => a.bpmnElementId === bpmnElementId)
const automated = resultAutomated.perActivity.find((a) => a.bpmnElementId === bpmnElementId)
if (!actual || !automated) continue // podría no aparecer si prob=0
expect(automated.expectedDirectCost).toBeLessThan(actual.expectedDirectCost)
}
})
it('actividades no automatable tienen el mismo costo en ambos escenarios', () => {
for (const act of resultActual.perActivity) {
if (AUTOMATABLE_IDS.has(act.bpmnElementId)) continue
const auto = resultAutomated.perActivity.find((a) => a.bpmnElementId === act.bpmnElementId)!
expect(auto.expectedDirectCost).toBeCloseTo(act.expectedDirectCost, 5)
}
})
it('ningún campo numérico del resultado automatizado es NaN', () => {
expect(Number.isNaN(resultAutomated.totalCost)).toBe(false)
expect(Number.isNaN(resultAutomated.totalDirectCost)).toBe(false)
expect(Number.isNaN(resultAutomated.totalIndirectCost)).toBe(false)
for (const act of resultAutomated.perActivity) {
expect(Number.isNaN(act.expectedDirectCost)).toBe(false)
expect(Number.isNaN(act.expectedTotalCost)).toBe(false)
expect(Number.isNaN(act.percentOfTotal)).toBe(false)
}
})
// ─── calculateRoi conecta con los resultados de simulación ─────────────
it('calculateRoi recibe costos de ambas simulaciones y devuelve KPIs válidos', () => {
const roi = calculateRoi({
costPerExecutionActual: resultActual.totalCost,
costPerExecutionAutomated: resultAutomated.totalCost,
...VOLUMETRY,
})
// Ahorro positivo (automated < actual)
expect(roi.savingsPerExecution).toBeGreaterThan(0)
expect(roi.annualSavings).toBeGreaterThan(0)
// Payback finito y positivo
expect(roi.paybackMonths).toBeGreaterThan(0)
expect(Number.isFinite(roi.paybackMonths)).toBe(true)
// Con inversión $50.000 y ahorro anual razonable, debería pagar dentro del horizonte de 3 años
// (esto depende de los costos calculados, pero verificamos que es un número razonable)
expect(roi.paybackYears).toBeGreaterThan(0)
// ROI anualizado positivo (ahorro > 0, inversión > 0)
expect(roi.roiAnnualPercent).toBeGreaterThan(0)
expect(Number.isNaN(roi.roiAnnualPercent)).toBe(false)
expect(Number.isNaN(roi.cumulativeSavingsHorizon)).toBe(false)
})
it('ahorro acumulado a 3 años supera la inversión si hay ahorro positivo suficiente', () => {
const roi = calculateRoi({
costPerExecutionActual: resultActual.totalCost,
costPerExecutionAutomated: resultAutomated.totalCost,
...VOLUMETRY,
})
if (roi.annualSavings * VOLUMETRY.analysisHorizonYears > VOLUMETRY.automationInvestment) {
expect(roi.cumulativeSavingsHorizon).toBeGreaterThan(0)
expect(roi.breaksEvenInHorizon).toBe(true)
} else {
// Si el ahorro es menor que la inversión en 3 años, el test es informativo
expect(roi.cumulativeSavingsHorizon).toBeDefined()
}
})
// ─── Transparencia metodológica: actividades automatable con costo cero ─
it('detecta actividades automatable con automatedCostFixed=0 para transparencia', () => {
// En nuestro fixture solo task_recv y task_score son automatable con costo > 0.
// Si alguien marca una actividad como automatable pero no pone costo,
// el resultado automatizado de esa actividad debe tener cost=0.
const activities = buildActivities(MEDIUM_XML)
// Forzar una actividad automatable con costo cero (simula campo no configurado)
const taskValid = activities.get('task_valid')
if (taskValid) {
activities.set('task_valid', { ...taskValid, automatable: true, automatedCostFixed: 0, automatedTimeMinutes: 0 })
}
const result = runSimulation({ ...BASE_INPUT, activities, scenario: 'automated' })
const validResult = result.perActivity.find((a) => a.bpmnElementId === 'task_valid')
if (validResult && validResult.executionProbability > 0) {
// automatable=true pero costos en 0 → contribuye con $0 al total automatizado
expect(validResult.expectedDirectCost).toBe(0)
expect(Number.isNaN(validResult.expectedDirectCost)).toBe(false)
}
})
})

View File

@@ -0,0 +1,447 @@
/**
* Test integrador del flujo ROI completo — Sprint 1 Etapa 3.
*
* Para cada sample BPMN verifica que el pipeline:
* parseBpmnXml → runSimulation (dual) → calculateRoi
* produce KPIs numéricos válidos y detecta las condiciones de transparencia
* metodológica correctamente.
*
* Los 4 casos:
* A) simple-linear SIN automatizables → automatableIds.size = 0
* B) medium-with-gateways, 3 automatable → KPIs positivos, payback razonable
* C) medium-with-gateways, 1 automatable en ceros + investment=0 → transparencia doble
* D) complex-with-loop → warnings preservados en ambos escenarios
*/
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { v4 as uuidv4 } from 'uuid'
import { parseBpmnXml, extractActivityElements, extractGatewayElements } from '@/domain/bpmn-parser'
import { runSimulation } from '@/domain/simulation'
import { calculateRoi } from '@/domain/roi'
import { formatCurrency, formatPercent } from '@/lib/format'
import { bpmnTypeToGatewayType } from '@/domain/types'
import type { Activity, GatewayConfig, SimulationInput } from '@/domain/types'
// ─── Helpers ──────────────────────────────────────────────────────────────────
function loadBpmn(name: string): string {
return readFileSync(resolve(__dirname, '../../public/sample-processes', name), 'utf-8')
}
function buildSimInput(
xml: string,
directCost: number,
automatableIds: Set<string>,
automatedCost: number,
automatedTime: number,
): SimulationInput {
const graph = parseBpmnXml(xml)
const actEls = extractActivityElements(graph)
const gwEls = extractGatewayElements(graph)
const processId = 'roi-test'
const activities = new Map<string, Activity>(
actEls.map((el) => [el.bpmnElementId, {
id: uuidv4(), processId, bpmnElementId: el.bpmnElementId,
name: el.name, type: el.type,
directCostFixed: directCost,
executionTimeMinutes: 60,
assignedResources: [],
automatable: automatableIds.has(el.bpmnElementId),
automatedCostFixed: automatableIds.has(el.bpmnElementId) ? automatedCost : 0,
automatedTimeMinutes: automatableIds.has(el.bpmnElementId) ? automatedTime : 0,
}])
)
const gateways = new Map<string, GatewayConfig>(
gwEls.map((el) => {
const gwType = bpmnTypeToGatewayType(el.gatewayType as any)
const n = el.outgoing.length
return [el.bpmnElementId, {
id: uuidv4(), processId, bpmnElementId: el.bpmnElementId,
gatewayType: gwType,
branches: el.outgoing.map((flowId) => ({
flowId, targetElementId: graph.flows.get(flowId)?.targetRef ?? '',
probability: gwType === 'parallel' ? 1.0 : parseFloat((1 / n).toFixed(4)),
})),
}]
})
)
return {
processXml: xml,
activities,
gateways,
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0.2 },
}
}
// Volumetría estándar para todos los tests (la especificada en el brief)
const VOL = { annualFrequency: 12_000, analysisHorizonYears: 3, automationInvestment: 50_000 }
// ─── CASO A: simple-linear SIN automatizables ─────────────────────────────────
describe('Caso A — simple-linear SIN automatizables', () => {
const xml = loadBpmn('simple-linear.bpmn')
const noAutomatable = new Set<string>()
const input = buildSimInput(xml, 200, noAutomatable, 0, 0)
const resultActual = runSimulation({ ...input, scenario: 'actual' })
const resultAutomated = runSimulation({ ...input, scenario: 'automated' })
it('simple-linear tiene 5 actividades', () => {
const graph = parseBpmnXml(xml)
const actEls = extractActivityElements(graph)
expect(actEls).toHaveLength(5)
})
it('automatableIds.size === 0: ninguna actividad marcada como automatizable', () => {
const graph = parseBpmnXml(xml)
const actEls = extractActivityElements(graph)
const automatableIds = actEls.filter((el) => noAutomatable.has(el.bpmnElementId))
expect(automatableIds).toHaveLength(0)
})
it('resultActual y resultAutomated tienen costos idénticos (sin automatizables)', () => {
expect(resultActual.totalCost).toBeCloseTo(resultAutomated.totalCost, 4)
})
it('ROI con costos idénticos: savingsPerExecution = 0, annualSavings = 0', () => {
const roi = calculateRoi({
costPerExecutionActual: resultActual.totalCost,
costPerExecutionAutomated: resultAutomated.totalCost,
...VOL,
})
expect(roi.savingsPerExecution).toBeCloseTo(0, 4)
expect(roi.annualSavings).toBeCloseTo(0, 2)
})
it('con ahorro cero: paybackMonths = Infinity', () => {
const roi = calculateRoi({
costPerExecutionActual: resultActual.totalCost,
costPerExecutionAutomated: resultAutomated.totalCost,
...VOL,
})
expect(roi.paybackMonths).toBe(Infinity)
})
it('con ahorro cero: breaksEvenInHorizon = false', () => {
const roi = calculateRoi({
costPerExecutionActual: resultActual.totalCost,
costPerExecutionAutomated: resultAutomated.totalCost,
...VOL,
})
expect(roi.breaksEvenInHorizon).toBe(false)
})
})
// ─── CASO B: medium-with-gateways con 3 automatizables ───────────────────────
describe('Caso B — medium-with-gateways, 3 automatizables, valores plausibles', () => {
const xml = loadBpmn('medium-with-gateways.bpmn')
// task_recv (prob≈1.0), task_score (prob≈0.5), task_analisis (prob≈0.5 post-parallel)
const automatable3 = new Set(['task_recv', 'task_score', 'task_analisis'])
const input = buildSimInput(xml, 200, automatable3, 20, 5)
const resultActual = runSimulation({ ...input, scenario: 'actual' })
const resultAutomated = runSimulation({ ...input, scenario: 'automated' })
const roi = calculateRoi({
costPerExecutionActual: resultActual.totalCost,
costPerExecutionAutomated: resultAutomated.totalCost,
...VOL,
})
it('reporta los KPIs reales del Caso B para inspección manual', () => {
// Este test siempre pasa — su propósito es mostrar valores en la consola
console.log('\n╔══════════════════════════════════════════════════════════╗')
console.log('║ CASO B: medium-with-gateways — KPIs del sistema ║')
console.log('╠══════════════════════════════════════════════════════════╣')
console.log(`║ Costo actual por ejecución: ${formatCurrency(resultActual.totalCost, 'USD').padEnd(26)}`)
console.log(`║ Costo automático por ejec.: ${formatCurrency(resultAutomated.totalCost, 'USD').padEnd(26)}`)
console.log('╠══════════════════════════════════════════════════════════╣')
console.log(`║ Ahorro por ejecución: ${formatCurrency(roi.savingsPerExecution, 'USD').padEnd(30)}`)
console.log(`║ Reducción %: ${formatPercent(roi.savingsPerExecutionPercent).padEnd(30)}`)
console.log(`║ Ahorro anual: ${formatCurrency(roi.annualSavings, 'USD').padEnd(30)}`)
console.log(`║ Payback: ${roi.paybackMonths.toFixed(1)} meses`.padEnd(57) + '║')
console.log(`║ Acumulado a 3 años: ${formatCurrency(roi.cumulativeSavingsHorizon, 'USD').padEnd(30)}`)
console.log(`║ ROI anualizado: ${formatPercent(roi.roiAnnualPercent).padEnd(30)}`)
console.log('╚══════════════════════════════════════════════════════════╝\n')
expect(true).toBe(true)
})
it('dump de costos por actividad — reconciliación aritmética completa', () => {
const autoByElem = new Map(resultAutomated.perActivity.map((a) => [a.bpmnElementId, a]))
// Construcción de la tabla en orden de procesamiento topológico
const all = resultActual.perActivity
.slice()
.sort((a, b) => b.executionProbability - a.executionProbability || a.activityName.localeCompare(b.activityName))
const AUTOMATABLE = new Set(['task_recv', 'task_score', 'task_analisis'])
let directActualSum = 0
let directAutoSum = 0
console.log('\n┌────────────────────────────────┬──────┬──────────────┬──────────────┐')
console.log('│ bpmnElementId │ prob │ directo act. │ directo auto │')
console.log('├────────────────────────────────┼──────┼──────────────┼──────────────┤')
for (const act of all) {
const aAuto = autoByElem.get(act.bpmnElementId)
const prob = act.executionProbability
const dAct = act.expectedDirectCost
const dAuto = aAuto?.expectedDirectCost ?? 0
const auto = AUTOMATABLE.has(act.bpmnElementId) ? '★' : ' '
directActualSum += dAct
directAutoSum += dAuto
const idPadded = act.bpmnElementId.padEnd(30)
const probStr = prob.toFixed(2).padStart(4)
const actStr = dAct.toFixed(2).padStart(12)
const autoStr = dAuto.toFixed(2).padStart(12)
console.log(`${auto}${idPadded}${probStr}${actStr}${autoStr}`)
}
console.log('├────────────────────────────────┼──────┼──────────────┼──────────────┤')
console.log(`│ SUMA DIRECTA │ │ ${directActualSum.toFixed(2).padStart(12)}${directAutoSum.toFixed(2).padStart(12)}`)
console.log(`│ + overhead 20% │ │ ${(directActualSum * 1.2).toFixed(2).padStart(12)}${(directAutoSum * 1.2).toFixed(2).padStart(12)}`)
console.log('└────────────────────────────────┴──────┴──────────────┴──────────────┘\n')
// Verificaciones aritméticas exactas
// sum probs de actividades: 1+1+0.5+0.5+0.5+0.5+1+0.5+0.5+0.5+1 = 7.5
const sumProbs = all.reduce((s, a) => s + a.executionProbability, 0)
expect(sumProbs).toBeCloseTo(7.5, 4)
// Directo actual = 7.5 × $200 = $1.500
expect(directActualSum).toBeCloseTo(1500, 2)
// Total con overhead = $1.800
expect(resultActual.totalCost).toBeCloseTo(1800, 2)
// Directo automatizado:
// task_recv(1×$20)+task_valid(1×$200)+task_score(0.5×$20)+task_pedir_docs(0.5×$200)
// +task_bureau(0.5×$200)+task_empleador(0.5×$200)+task_analisis(1×$20)
// +task_aprobar(0.5×$200)+task_rechazar(0.5×$200)+task_desembolso(0.5×$200)+task_archivo(1×$200)
// = $20+$200+$10+$100+$100+$100+$20+$100+$100+$100+$200 = $1.050
expect(directAutoSum).toBeCloseTo(1050, 2)
// Total automatizado con overhead = $1.260
expect(resultAutomated.totalCost).toBeCloseTo(1260, 2)
})
it('costAutomated < costActual (hay ahorro)', () => {
expect(resultAutomated.totalCost).toBeLessThan(resultActual.totalCost)
})
it('savingsPerExecution > 0 (ahorro positivo por ejecución)', () => {
expect(roi.savingsPerExecution).toBeGreaterThan(0)
})
it('annualSavings > 0 (ahorro anual positivo)', () => {
expect(roi.annualSavings).toBeGreaterThan(0)
})
it('paybackMonths es finito y positivo', () => {
expect(Number.isFinite(roi.paybackMonths)).toBe(true)
expect(roi.paybackMonths).toBeGreaterThan(0)
})
it('paybackMonths < 24 (recupera la inversión en menos de 2 años con datos plausibles)', () => {
expect(roi.paybackMonths).toBeLessThan(24)
})
it('ROI anualizado > 100% (inversión se multiplica en el horizonte)', () => {
expect(roi.roiAnnualPercent).toBeGreaterThan(100)
})
it('breaksEvenInHorizon = true (se recupera dentro de 3 años)', () => {
expect(roi.breaksEvenInHorizon).toBe(true)
})
it('ningún KPI es NaN', () => {
expect(Number.isNaN(roi.savingsPerExecution)).toBe(false)
expect(Number.isNaN(roi.annualSavings)).toBe(false)
expect(Number.isNaN(roi.cumulativeSavingsHorizon)).toBe(false)
expect(Number.isNaN(roi.roiAnnualPercent)).toBe(false)
expect(Number.isNaN(roi.paybackMonths)).toBe(false)
})
it('tabla de comparación: task_recv tiene ahorro diferente de cero', () => {
const actualRecv = resultActual.perActivity.find((a) => a.bpmnElementId === 'task_recv')
const autoRecv = resultAutomated.perActivity.find((a) => a.bpmnElementId === 'task_recv')
expect(actualRecv).toBeDefined()
expect(autoRecv).toBeDefined()
expect(autoRecv!.expectedDirectCost).toBeLessThan(actualRecv!.expectedDirectCost)
})
it('tabla de comparación: actividades no automatable tienen costos idénticos en ambos escenarios', () => {
const nonAutomatableIds = ['task_valid', 'task_bureau', 'task_empleador', 'task_archivo']
for (const id of nonAutomatableIds) {
const actual = resultActual.perActivity.find((a) => a.bpmnElementId === id)
const auto = resultAutomated.perActivity.find((a) => a.bpmnElementId === id)
if (!actual || !auto) continue
expect(auto.expectedDirectCost).toBeCloseTo(actual.expectedDirectCost, 4)
}
})
it('probabilidades de ejecución idénticas entre escenarios (el grafo no cambia)', () => {
for (const actActual of resultActual.perActivity) {
const actAuto = resultAutomated.perActivity.find(
(a) => a.bpmnElementId === actActual.bpmnElementId
)
expect(actAuto).toBeDefined()
expect(actAuto!.executionProbability).toBeCloseTo(actActual.executionProbability, 5)
}
})
})
// ─── CASO C: transparencia metodológica (ambas condiciones) ──────────────────
describe('Caso C — medium-with-gateways, 1 automatable en ceros + investment=0', () => {
const xml = loadBpmn('medium-with-gateways.bpmn')
const graph = parseBpmnXml(xml)
const actEls = extractActivityElements(graph)
const gwEls = extractGatewayElements(graph)
// Construir actividades: task_valid marcada como automatable pero con costo=0 y tiempo=0
const activities = new Map<string, Activity>(
actEls.map((el) => [el.bpmnElementId, {
id: uuidv4(), processId: 'c-test', bpmnElementId: el.bpmnElementId,
name: el.name, type: el.type,
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
automatable: el.bpmnElementId === 'task_valid',
automatedCostFixed: 0, // cero — trigger de transparencia
automatedTimeMinutes: 0, // cero — trigger de transparencia
}])
)
const gateways = new Map<string, GatewayConfig>(
gwEls.map((el) => {
const gwType = bpmnTypeToGatewayType(el.gatewayType as any)
const n = el.outgoing.length
return [el.bpmnElementId, {
id: uuidv4(), processId: 'c-test', bpmnElementId: el.bpmnElementId,
gatewayType: gwType,
branches: el.outgoing.map((flowId) => ({
flowId, targetElementId: graph.flows.get(flowId)?.targetRef ?? '',
probability: gwType === 'parallel' ? 1.0 : parseFloat((1 / n).toFixed(4)),
})),
}]
})
)
const input: SimulationInput = {
processXml: xml, activities, gateways, resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0.2 },
}
const resultActual = runSimulation({ ...input, scenario: 'actual' })
const resultAutomated = runSimulation({ ...input, scenario: 'automated' })
// investment=0 para el segundo trigger de transparencia
const roi = calculateRoi({
costPerExecutionActual: resultActual.totalCost,
costPerExecutionAutomated: resultAutomated.totalCost,
annualFrequency: 12_000,
analysisHorizonYears: 3,
automationInvestment: 0, // ← trigger de transparencia
})
it('task_valid automatable con costo=0 → en escenario automatizado, task_valid contribuye $0 de costo directo', () => {
const taskValidAuto = resultAutomated.perActivity.find((a) => a.bpmnElementId === 'task_valid')
expect(taskValidAuto).toBeDefined()
// automatable=true pero costo=0 → contribuye con $0 al directo automatizado
expect(taskValidAuto!.expectedDirectCost).toBeCloseTo(0, 4)
})
it('TransparencySection Condición 1: hay 1 actividad automatable con costos en cero', () => {
const zeroCostAutomatable = actEls.filter((el) => {
const act = activities.get(el.bpmnElementId)
return act?.automatable && act.automatedCostFixed === 0 && act.automatedTimeMinutes === 0
})
expect(zeroCostAutomatable).toHaveLength(1)
expect(zeroCostAutomatable[0].bpmnElementId).toBe('task_valid')
})
it('TransparencySection Condición 2: investment=0 → paybackMonths=0 (inmediato)', () => {
expect(roi.paybackMonths).toBe(0)
})
it('TransparencySection Condición 2: investment=0 → roiAnnualPercent=Infinity', () => {
expect(roi.roiAnnualPercent).toBe(Infinity)
})
it('ambas condiciones de transparencia coexisten en el mismo proceso', () => {
const zeroCostAutomatable = actEls.filter((el) => {
const act = activities.get(el.bpmnElementId)
return act?.automatable && act.automatedCostFixed === 0 && act.automatedTimeMinutes === 0
})
// Condición 1: al menos 1 automatable con costo=0
expect(zeroCostAutomatable.length).toBeGreaterThan(0)
// Condición 2: investment=0
expect(roi.paybackMonths).toBe(0)
expect(roi.roiAnnualPercent).toBe(Infinity)
})
it('el ahorro sigue siendo positivo aunque task_valid tenga costo=0 en automático (la reducción es real)', () => {
// task_valid automatable con costo=0 reduce el total del escenario automatizado
expect(resultAutomated.totalCost).toBeLessThan(resultActual.totalCost)
expect(roi.savingsPerExecution).toBeGreaterThan(0)
})
})
// ─── CASO D: complex-with-loop + warnings ─────────────────────────────────────
describe('Caso D — complex-with-loop, warnings preservados en ambos escenarios', () => {
const xml = loadBpmn('complex-with-loop.bpmn')
const automatable = new Set(['task_preparar', 'task_inspeccion'])
const input = buildSimInput(xml, 200, automatable, 25, 5)
const resultActual = runSimulation({ ...input, scenario: 'actual' })
const resultAutomated = runSimulation({ ...input, scenario: 'automated' })
it('el escenario actual produce warnings (loop detectado)', () => {
expect(resultActual.warnings.length).toBeGreaterThan(0)
const loopWarning = resultActual.warnings.some(
(w) => w.toLowerCase().includes('loop') || w.toLowerCase().includes('ciclo') || w.toLowerCase().includes('visitad')
)
expect(loopWarning).toBe(true)
})
it('el escenario automatizado también tiene los mismos warnings de loop', () => {
expect(resultAutomated.warnings.length).toBeGreaterThan(0)
// La estructura del grafo no cambia entre escenarios → mismo warning
expect(resultAutomated.warnings.length).toBe(resultActual.warnings.length)
})
it('el tab ROI (cambio de tab) NO silencia el warning — los datos de warnings coexisten', () => {
// Este test valida que el SimulationResult preserva el array de warnings
// independientemente del escenario. La UI del tab ROI no tiene WarningsBanner
// (solo Actual y Automatizado lo tienen), pero los datos existen.
expect(resultActual.warnings).toBeDefined()
expect(Array.isArray(resultActual.warnings)).toBe(true)
expect(resultAutomated.warnings).toBeDefined()
expect(Array.isArray(resultAutomated.warnings)).toBe(true)
})
it('aún con loop, el totalCost no es NaN', () => {
expect(Number.isNaN(resultActual.totalCost)).toBe(false)
expect(Number.isNaN(resultAutomated.totalCost)).toBe(false)
})
it('aún con loop, la diferencia de costos entre escenarios es real y positiva', () => {
expect(resultAutomated.totalCost).toBeLessThan(resultActual.totalCost)
})
it('aún con loop, calculateRoi no produce NaN ni errores', () => {
const roi = calculateRoi({
costPerExecutionActual: resultActual.totalCost,
costPerExecutionAutomated: resultAutomated.totalCost,
...VOL,
})
expect(Number.isNaN(roi.savingsPerExecution)).toBe(false)
expect(Number.isNaN(roi.annualSavings)).toBe(false)
expect(Number.isNaN(roi.cumulativeSavingsHorizon)).toBe(false)
expect(roi.savingsPerExecution).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,216 @@
/**
* Tests del export CSV para el tab "Comparación + ROI" (Sprint 1 — Etapa 4).
* Verifica las 16 columnas, la metadata extendida, sufijos de archivo y
* compatibilidad con el comportamiento anterior (tabs actual y automatizado).
*/
import { describe, it, expect } from 'vitest'
import { generateCsvContent } from '@/lib/export/csv-export'
import type { Process, Simulation, Activity, SimulationResult } from '@/domain/types'
// ─── Fixtures ─────────────────────────────────────────────────────────────────
function makeProcess(extras: Partial<Process> = {}): Process {
return {
id: 'p1', name: 'Proceso de Crédito', clientName: 'Banco XYZ',
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
createdAt: 0, updatedAt: 0, ...extras,
}
}
const perActivityActual: SimulationResult['perActivity'] = [
{
activityId: 'a1', bpmnElementId: 'task_recv', activityName: 'Recibir solicitud',
expectedDirectCost: 200, expectedIndirectCost: 40, expectedTotalCost: 240,
percentOfTotal: 60, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 60, resourceCostBreakdown: [],
},
{
activityId: 'a2', bpmnElementId: 'task_valid', activityName: 'Validar datos',
expectedDirectCost: 100, expectedIndirectCost: 20, expectedTotalCost: 120,
percentOfTotal: 40, executionProbability: 0.8, expectedExecutions: 0.8,
executionTimeMinutes: 30, resourceCostBreakdown: [],
},
]
const perActivityAutomated: SimulationResult['perActivity'] = [
{
activityId: 'a1', bpmnElementId: 'task_recv', activityName: 'Recibir solicitud',
expectedDirectCost: 20, expectedIndirectCost: 4, expectedTotalCost: 24,
percentOfTotal: 40, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 5, resourceCostBreakdown: [],
},
{
activityId: 'a2', bpmnElementId: 'task_valid', activityName: 'Validar datos',
expectedDirectCost: 100, expectedIndirectCost: 20, expectedTotalCost: 120,
percentOfTotal: 60, executionProbability: 0.8, expectedExecutions: 0.8,
executionTimeMinutes: 30, resourceCostBreakdown: [],
},
]
const mockSimulation: Simulation = {
id: 'sim1', processId: 'p1',
executedAt: new Date('2026-05-13T10:00:00Z').getTime(),
result: {
totalCost: 360, totalDirectCost: 300, totalIndirectCost: 60, totalTimeMinutes: 84,
perActivity: perActivityActual, perResource: [], warnings: [],
},
resultAutomated: {
totalCost: 144, totalDirectCost: 120, totalIndirectCost: 24, totalTimeMinutes: 8,
perActivity: perActivityAutomated, perResource: [], warnings: [],
},
}
const mockActivities: Activity[] = [
{
id: 'a1', processId: 'p1', bpmnElementId: 'task_recv', name: 'Recibir solicitud', type: 'task',
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
automatable: true, automatedCostFixed: 20, automatedTimeMinutes: 5,
},
{
id: 'a2', processId: 'p1', bpmnElementId: 'task_valid', name: 'Validar datos', type: 'task',
directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
},
]
// ─── CSV ROI: estructura de columnas ──────────────────────────────────────────
describe('CSV tab=roi — 16 columnas', () => {
it('tiene exactamente 16 columnas en el header', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
const headerLine = csvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
// Papa.unparse envuelve en comillas — contar separadores + 1
const cols = headerLine.split('","').length
expect(cols).toBe(16)
})
it('tiene los headers esperados (muestra)', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
expect(csv).toContain('ID BPMN')
expect(csv).toContain('Automatizable')
expect(csv).toContain('Costo actual total')
expect(csv).toContain('Costo automatizado total')
expect(csv).toContain('Ahorro')
expect(csv).toContain('Tiempo automatizado')
expect(csv).toContain('Recursos asignados')
})
})
describe('CSV tab=roi — metadata extendida', () => {
it('contiene campos de ROI en la metadata', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
expect(csv).toContain('# Ahorro por ejecución')
expect(csv).toContain('# Ahorro anual')
expect(csv).toContain('# Payback (meses)')
expect(csv).toContain('# ROI anualizado')
expect(csv).toContain('# Frecuencia anual')
expect(csv).toContain('# Horizonte de análisis')
expect(csv).toContain('# Inversión en automatización')
})
it('costo total actual y automatizado están en la metadata', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
expect(csv).toContain('# Costo total actual')
expect(csv).toContain('# Costo total automatizado')
// Valores concretos del fixture: 360 y 144
expect(csv).toContain('360.00')
expect(csv).toContain('144.00')
})
it('metadata incluye el horizonte y frecuencia configurados', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
expect(csv).toContain('# Frecuencia anual: 1200')
expect(csv).toContain('# Horizonte de análisis: 3 años')
})
})
describe('CSV tab=roi — datos de actividades', () => {
it('actividad automatable tiene "true" en la columna Automatizable', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
// task_recv es automatable=true
expect(csv).toContain('"true"')
})
it('actividad NO automatable tiene "false" en la columna Automatizable', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
expect(csv).toContain('"false"')
})
it('ahorro de task_recv: 240 - 24 = 216 aparece en los datos', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
expect(csv).toContain('216.00')
})
it('actividad no automatable tiene ahorro 0 (costos iguales)', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
// task_valid: 120 - 120 = 0
expect(csv).toContain('0.00')
})
it('N+1 filas (N actividades + 1 header)', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
const dataLines = csvNoBom.split('\r\n').filter((l) => l.trim() && !l.startsWith('#'))
expect(dataLines).toHaveLength(perActivityActual.length + 1)
})
})
describe('CSV tab=roi — separadores y BOM', () => {
it('BOM UTF-8 presente', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
expect(csv.charCodeAt(0)).toBe(0xFEFF)
})
it('USD: separador coma', () => {
const csv = generateCsvContent({ process: makeProcess({ currency: 'USD' }), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
const headerLine = csvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
expect(headerLine).toContain('","')
})
it('PYG: separador punto y coma', () => {
const csv = generateCsvContent({ process: makeProcess({ currency: 'PYG' }), simulation: mockSimulation, resources: [], tab: 'roi', activities: mockActivities })
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
const headerLine = csvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
expect(headerLine).toContain('";"')
})
})
// ─── CSV tab=automatizado ─────────────────────────────────────────────────────
describe('CSV tab=automatizado', () => {
it('usa los datos del escenario automatizado (totalCost=144)', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'automatizado' })
expect(csv).toContain('# Escenario: Automatizado')
expect(csv).toContain('144.00')
})
it('tiene 10 columnas (mismo formato que actual)', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'automatizado' })
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
const headerLine = csvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
const cols = headerLine.split('","').length
expect(cols).toBe(10)
})
})
// ─── CSV tab=actual — regresión ───────────────────────────────────────────────
describe('CSV tab=actual (regresión)', () => {
it('tab=actual funciona igual que antes (escenario actual, 10 cols)', () => {
const csv = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'actual' })
expect(csv).toContain('# Escenario: Actual')
expect(csv).toContain('360.00')
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
const headerLine = csvNoBom.split('\r\n').find((l) => !l.startsWith('#') && l.trim())!
expect(headerLine.split('","').length).toBe(10)
})
it('tab=undefined → igual que tab=actual', () => {
const csvDefault = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [] })
const csvActual = generateCsvContent({ process: makeProcess(), simulation: mockSimulation, resources: [], tab: 'actual' })
expect(csvDefault).toBe(csvActual)
})
})

View File

@@ -8,6 +8,7 @@ function makeProcess(currency: string, extras: Partial<Process> = {}): Process {
return {
id: 'p1', name: 'Proceso de Ventas', clientName: 'Empresa ABC',
bpmnXml: '', currency, overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0, ...extras,
}
}

View File

@@ -26,6 +26,7 @@ function buildInput(xml: string, directCost: number): SimulationInput {
actEls.map((el) => [el.bpmnElementId, {
id: uuidv4(), processId, bpmnElementId: el.bpmnElementId, name: el.name,
type: el.type, directCostFixed: directCost, executionTimeMinutes: 30, assignedResources: [],
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
}])
)
@@ -63,6 +64,7 @@ describe('Medición de tamaños de CSV — 3 sample BPMNs', () => {
const process: Process = {
id: 'p1', name: bpmn.name, clientName: bpmn.client,
bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0,
}

View File

@@ -0,0 +1,206 @@
/**
* Tests del export PDF para el tab "Comparación + ROI" (Sprint 1 — Etapa 4).
* También verifica sufijos de nombre de archivo para los 3 tabs.
* jsPDF y autotable se mockean.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ─── Mock de jsPDF ────────────────────────────────────────────────────────────
const mockDoc = {
setProperties: vi.fn(),
setFont: vi.fn(),
setFontSize: vi.fn(),
setTextColor: vi.fn(),
setFillColor: vi.fn(),
setDrawColor: vi.fn(),
setLineWidth: vi.fn(),
text: vi.fn(),
rect: vi.fn(),
roundedRect: vi.fn(),
line: vi.fn(),
addImage: vi.fn(),
addPage: vi.fn(),
setPage: vi.fn(),
save: vi.fn(),
splitTextToSize: vi.fn().mockImplementation((text: string) => [text]),
internal: { getNumberOfPages: vi.fn().mockReturnValue(4) },
}
vi.mock('jspdf', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function MockJsPDF(this: any) { return mockDoc }
return { jsPDF: MockJsPDF }
})
const mockAutoTable = vi.fn()
vi.mock('jspdf-autotable', () => ({ default: mockAutoTable }))
// ─── Fixtures ─────────────────────────────────────────────────────────────────
import type { Process, Simulation, Activity } from '@/domain/types'
const mockProcess: Process = {
id: 'p1', name: 'Proceso de Aprobación de Crédito', clientName: 'Banco XYZ',
bpmnXml: '', currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 1200, analysisHorizonYears: 3, automationInvestment: 50_000,
createdAt: 0, updatedAt: 0,
}
const perActivityActual = [
{
activityId: 'a1', bpmnElementId: 'task_recv', activityName: 'Recibir solicitud',
expectedDirectCost: 200, expectedIndirectCost: 40, expectedTotalCost: 240,
percentOfTotal: 60, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 60, resourceCostBreakdown: [],
},
{
activityId: 'a2', bpmnElementId: 'task_valid', activityName: 'Validar datos',
expectedDirectCost: 100, expectedIndirectCost: 20, expectedTotalCost: 120,
percentOfTotal: 40, executionProbability: 0.8, expectedExecutions: 0.8,
executionTimeMinutes: 30, resourceCostBreakdown: [],
},
]
const perActivityAutomated = [
{
activityId: 'a1', bpmnElementId: 'task_recv', activityName: 'Recibir solicitud',
expectedDirectCost: 20, expectedIndirectCost: 4, expectedTotalCost: 24,
percentOfTotal: 40, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 5, resourceCostBreakdown: [],
},
{
activityId: 'a2', bpmnElementId: 'task_valid', activityName: 'Validar datos',
expectedDirectCost: 100, expectedIndirectCost: 20, expectedTotalCost: 120,
percentOfTotal: 60, executionProbability: 0.8, expectedExecutions: 0.8,
executionTimeMinutes: 30, resourceCostBreakdown: [],
},
]
const mockSimulation: Simulation = {
id: 'sim1', processId: 'p1',
executedAt: new Date('2026-05-13T10:00:00Z').getTime(),
result: {
totalCost: 360, totalDirectCost: 300, totalIndirectCost: 60, totalTimeMinutes: 84,
perActivity: perActivityActual, perResource: [], warnings: [],
},
resultAutomated: {
totalCost: 144, totalDirectCost: 120, totalIndirectCost: 24, totalTimeMinutes: 8,
perActivity: perActivityAutomated, perResource: [], warnings: [],
},
}
const mockActivities: Activity[] = [
{
id: 'a1', processId: 'p1', bpmnElementId: 'task_recv', name: 'Recibir solicitud', type: 'task',
directCostFixed: 200, executionTimeMinutes: 60, assignedResources: [],
automatable: true, automatedCostFixed: 20, automatedTimeMinutes: 5,
},
{
id: 'a2', processId: 'p1', bpmnElementId: 'task_valid', name: 'Validar datos', type: 'task',
directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
},
]
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('exportToPdf — tab=roi', () => {
beforeEach(() => { vi.clearAllMocks() })
it('no lanza error con tab=roi y parámetros válidos', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await expect(
exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'roi', activities: mockActivities })
).resolves.not.toThrow()
})
it('tab=roi: llama a addPage al menos 3 veces (4 páginas)', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'roi', activities: mockActivities })
expect(mockDoc.addPage.mock.calls.length).toBeGreaterThanOrEqual(3)
})
it('tab=roi: autoTable llamado con 6 columnas en el head (tabla comparativa)', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'roi', activities: mockActivities })
// autoTable puede llamarse múltiples veces; la tabla de comparación tiene 6 cols en head
const roiTableCall = mockAutoTable.mock.calls.find((call) => {
const head = call[1]?.head
return Array.isArray(head) && Array.isArray(head[0]) && head[0].length === 6
})
expect(roiTableCall).toBeDefined()
})
it('tab=roi: nombre de archivo termina en _roi.pdf', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'roi', activities: mockActivities })
const saveName = mockDoc.save.mock.calls[0][0] as string
expect(saveName).toMatch(/_roi\.pdf$/)
})
it('tab=roi: setProperties incluye "retorno de inversión" en subject', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'roi', activities: mockActivities })
expect(mockDoc.setProperties).toHaveBeenCalledWith(
expect.objectContaining({ subject: expect.stringContaining('retorno') })
)
})
it('tab=roi: NO llama a addImage (el tab ROI no tiene heatmap)', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
const fakeDataUrl = 'data:image/jpeg;base64,/9j/fake'
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: fakeDataUrl, tab: 'roi', activities: mockActivities })
// El ROI PDF no embebe heatmap aunque se pase imageData
expect(mockDoc.addImage).not.toHaveBeenCalled()
})
it('tab=roi sin resultAutomated: no lanza error (usa result como fallback)', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
const simSinAuto = { ...mockSimulation, resultAutomated: undefined }
await expect(
exportToPdf({ process: mockProcess, simulation: simSinAuto, resources: [], heatmapImageData: null, tab: 'roi', activities: mockActivities })
).resolves.not.toThrow()
})
})
describe('exportToPdf — tab=actual', () => {
beforeEach(() => { vi.clearAllMocks() })
it('tab=actual: nombre de archivo termina en _actual.pdf', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'actual' })
expect(mockDoc.save.mock.calls[0][0]).toMatch(/_actual\.pdf$/)
})
it('tab=actual: contiene el proceso en el título (setProperties)', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'actual' })
expect(mockDoc.setProperties).toHaveBeenCalledWith(
expect.objectContaining({ title: expect.stringContaining('Proceso de Aprobación de Crédito') })
)
})
it('tab=undefined (default): se comporta como actual', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null })
expect(mockDoc.save.mock.calls[0][0]).toMatch(/_actual\.pdf$/)
})
})
describe('exportToPdf — tab=automatizado', () => {
beforeEach(() => { vi.clearAllMocks() })
it('tab=automatizado: nombre de archivo termina en _automatizado.pdf', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'automatizado' })
expect(mockDoc.save.mock.calls[0][0]).toMatch(/_automatizado\.pdf$/)
})
it('tab=automatizado: no lanza error', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await expect(
exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null, tab: 'automatizado' })
).resolves.not.toThrow()
})
})

View File

@@ -46,6 +46,7 @@ const mockProcess: Process = {
clientName: 'Banco XYZ',
bpmnXml: '', currency: 'USD',
overheadPercentage: 0.2,
annualFrequency: 1000, analysisHorizonYears: 3, automationInvestment: 0,
createdAt: 0, updatedAt: 0,
}

View File

@@ -6,3 +6,19 @@ import '@testing-library/jest-dom'
if (typeof HTMLElement !== 'undefined') {
HTMLElement.prototype.scrollIntoView = () => {}
}
// jsdom no implementa ResizeObserver (usado por Radix UI Slider y otros)
if (typeof ResizeObserver === 'undefined') {
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
}
// jsdom no implementa PointerEvent (usado por Radix UI primitivos)
if (typeof PointerEvent === 'undefined') {
global.PointerEvent = class PointerEvent extends MouseEvent {
constructor(type: string, init?: PointerEventInit) { super(type, init) }
} as typeof PointerEvent
}

View File

@@ -0,0 +1,136 @@
/**
* Tests de invalidación del simulation store.
*
* Regla dura del Sprint 1: cualquier mutación en Activity o Process invalida
* AMBOS resultados (resultActual y resultAutomated) a null simultáneamente.
* Nunca puede haber un resultado "nuevo" y el otro "viejo".
*
* Nota: los tests poblan el store con setState() directamente (sin Dexie)
* porque prueban el comportamiento en memoria, no la persistencia.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { useSimulationStore } from '@/store/simulation-store'
import { useProcessStore } from '@/store/process-store'
import type { SimulationResult, Activity, Process } from '@/domain/types'
// Resultado mock mínimo para poblar el store sin Dexie
function makeMockResult(totalCost = 1000): SimulationResult {
return {
totalCost, totalDirectCost: totalCost * 0.8, totalIndirectCost: totalCost * 0.2,
totalTimeMinutes: 60, perActivity: [], perResource: [], warnings: [],
}
}
function setMockResults(actual = makeMockResult(), automated = makeMockResult(500)) {
useSimulationStore.setState({
resultActual: actual,
resultAutomated: automated,
lastSimulatedAt: new Date(),
})
}
beforeEach(() => {
// Resetear ambos stores antes de cada test para aislamiento
useSimulationStore.getState().reset()
useProcessStore.setState({
currentProcess: null, activities: [], gateways: [], resources: [],
isLoading: false, error: null,
})
})
// ─── invalidateResults() directa ─────────────────────────────────────────────
describe('simulation store — invalidateResults()', () => {
it('pone resultActual y resultAutomated a null', () => {
setMockResults()
expect(useSimulationStore.getState().resultActual).not.toBeNull()
expect(useSimulationStore.getState().resultAutomated).not.toBeNull()
useSimulationStore.getState().invalidateResults()
expect(useSimulationStore.getState().resultActual).toBeNull()
expect(useSimulationStore.getState().resultAutomated).toBeNull()
})
it('también limpia lastSimulatedAt', () => {
setMockResults()
expect(useSimulationStore.getState().lastSimulatedAt).not.toBeNull()
useSimulationStore.getState().invalidateResults()
expect(useSimulationStore.getState().lastSimulatedAt).toBeNull()
})
it('setState directo establece resultados independientes para actual y automated', () => {
const mockA = makeMockResult(1000)
const mockB = makeMockResult(500)
setMockResults(mockA, mockB)
const state = useSimulationStore.getState()
expect(state.resultActual!.totalCost).toBe(1000)
expect(state.resultAutomated!.totalCost).toBe(500)
expect(state.lastSimulatedAt).toBeInstanceOf(Date)
})
})
// ─── Invalidación reactiva vía suscripción al process store ──────────────────
describe('simulation store — invalidación por cambio en process store', () => {
it('cambio en activities invalida ambos resultados', () => {
setMockResults()
expect(useSimulationStore.getState().resultActual).not.toBeNull()
useProcessStore.setState({ activities: [] })
expect(useSimulationStore.getState().resultActual).toBeNull()
expect(useSimulationStore.getState().resultAutomated).toBeNull()
})
it('cambio en currentProcess invalida ambos resultados', () => {
setMockResults()
const proc: Process = {
id: 'new-proc', name: 'Nuevo', clientName: '', bpmnXml: '<def/>',
currency: 'USD', overheadPercentage: 0.2,
annualFrequency: 500, analysisHorizonYears: 2, automationInvestment: 10_000,
createdAt: Date.now(), updatedAt: Date.now(),
}
useProcessStore.setState({ currentProcess: proc })
expect(useSimulationStore.getState().resultActual).toBeNull()
expect(useSimulationStore.getState().resultAutomated).toBeNull()
})
it('editar campo de actividad invalida ambos resultados', () => {
// Test explícito pedido en el brief: simula lo que hace updateActivity
setMockResults()
const newActivities: Activity[] = [{
id: 'a1', processId: 'p1', bpmnElementId: 'task_1', name: 'Tarea',
type: 'task',
directCostFixed: 999, // campo modificado
executionTimeMinutes: 30, assignedResources: [],
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
}]
useProcessStore.setState({ activities: newActivities })
expect(useSimulationStore.getState().resultActual).toBeNull()
expect(useSimulationStore.getState().resultAutomated).toBeNull()
})
it('setState con las MISMAS activities (mismo reference) NO invalida', () => {
const activities: Activity[] = [{
id: 'a1', processId: 'p1', bpmnElementId: 't1', name: 'T',
type: 'task', directCostFixed: 100, executionTimeMinutes: 30,
assignedResources: [], automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
}]
useProcessStore.setState({ activities })
setMockResults()
// Misma referencia: no debe invalidar
useProcessStore.setState({ activities })
expect(useSimulationStore.getState().resultActual).not.toBeNull()
})
})