feat: MVP Fase 0 — Process Cost Platform v0.1.0

Plataforma completa de análisis de costos operativos basada en BPMN 2.0.

Funcionalidades incluidas:
- Import de archivos BPMN 2.0 por drag & drop + 3 procesos de ejemplo
- Visualización read-only del diagrama (bpmn-js)
- Configuración de actividades (costo, tiempo, recursos asignados)
- CRUD de recursos (rol, persona, sistema, equipo, insumo)
- Configuración global (moneda, overhead %, nombre, cliente)
- Motor de simulación determinístico agregado con propagación de gateways XOR/AND
- Reporte visual con mapa de calor en dos modos (relativo/absoluto)
- Gráficos: KPIs, top actividades, composición, costo por recurso
- Export PDF (jsPDF + html2canvas) y CSV (papaparse)
- Persistencia en IndexedDB (Dexie)
- 268 tests unitarios/integración + 6 E2E con Playwright
- Deploy: https://process-cost-platform.pages.dev

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 01:58:40 -03:00
commit bed161c92a
97 changed files with 19638 additions and 0 deletions

View File

@@ -0,0 +1,210 @@
import { describe, it, expect } from 'vitest'
import { parseBpmnXml, extractActivityElements, extractGatewayElements } from '@/domain/bpmn-parser'
import { runSimulation } from '@/domain/simulation'
import type { Activity, Resource, GatewayConfig, SimulationInput } from '@/domain/types'
// BPMN mínimo para tests — proceso lineal de 2 tareas
const LINEAR_BPMN = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" targetNamespace="test">
<process id="proc1" isExecutable="false">
<startEvent id="start"><outgoing>f1</outgoing></startEvent>
<task id="taskA" name="Tarea A"><incoming>f1</incoming><outgoing>f2</outgoing></task>
<task id="taskB" name="Tarea B"><incoming>f2</incoming><outgoing>f3</outgoing></task>
<endEvent id="end"><incoming>f3</incoming></endEvent>
<sequenceFlow id="f1" sourceRef="start" targetRef="taskA"/>
<sequenceFlow id="f2" sourceRef="taskA" targetRef="taskB"/>
<sequenceFlow id="f3" sourceRef="taskB" targetRef="end"/>
</process>
</definitions>`
// BPMN con gateway XOR
const XOR_BPMN = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" targetNamespace="test">
<process id="proc2" isExecutable="false">
<startEvent id="start"><outgoing>f1</outgoing></startEvent>
<task id="taskA" name="Evaluar"><incoming>f1</incoming><outgoing>f2</outgoing></task>
<exclusiveGateway id="gw1" name="Decision"><incoming>f2</incoming><outgoing>f3</outgoing><outgoing>f4</outgoing></exclusiveGateway>
<task id="taskB" name="Camino Si"><incoming>f3</incoming><outgoing>f5</outgoing></task>
<task id="taskC" name="Camino No"><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>`
describe('parseBpmnXml', () => {
it('parsea proceso lineal y extrae nodos y flujos', () => {
const graph = parseBpmnXml(LINEAR_BPMN)
expect(graph.nodes.size).toBe(4) // start, taskA, taskB, end
expect(graph.flows.size).toBe(3)
expect(graph.startEventIds).toEqual(['start'])
})
it('extrae actividades correctamente', () => {
const graph = parseBpmnXml(LINEAR_BPMN)
const activities = extractActivityElements(graph)
expect(activities).toHaveLength(2)
expect(activities[0].bpmnElementId).toBe('taskA')
expect(activities[1].bpmnElementId).toBe('taskB')
})
it('extrae gateways divergentes', () => {
const graph = parseBpmnXml(XOR_BPMN)
const gateways = extractGatewayElements(graph)
expect(gateways).toHaveLength(1)
expect(gateways[0].bpmnElementId).toBe('gw1')
expect(gateways[0].outgoing).toHaveLength(2)
})
it('lanza error si el XML no tiene proceso', () => {
expect(() => parseBpmnXml('<bad/>')).toThrow()
})
})
describe('runSimulation', () => {
const makeResource = (id: string, name: string, costPerHour: number): Resource => ({
id,
processId: 'proc1',
name,
type: 'role',
costPerHour,
})
const makeActivity = (
bpmnElementId: string,
name: string,
directCost: number,
minutes: number,
resources: Array<{ resourceId: string; utilizationPercent: number }> = []
): Activity => ({
id: `act-${bpmnElementId}`,
processId: 'proc1',
bpmnElementId,
name,
type: 'task',
directCostFixed: directCost,
executionTimeMinutes: minutes,
assignedResources: resources,
})
it('calcula costo total sin recursos — proceso lineal', () => {
const activityA = makeActivity('taskA', 'Tarea A', 100, 30)
const activityB = makeActivity('taskB', 'Tarea B', 200, 60)
const input: SimulationInput = {
processXml: LINEAR_BPMN,
activities: new Map([
['taskA', activityA],
['taskB', activityB],
]),
gateways: new Map(),
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
}
const result = runSimulation(input)
expect(result.totalDirectCost).toBe(300)
expect(result.totalIndirectCost).toBe(0)
expect(result.totalCost).toBe(300)
expect(result.warnings).toHaveLength(0)
})
it('aplica overhead correctamente', () => {
const activityA = makeActivity('taskA', 'Tarea A', 100, 30)
const input: SimulationInput = {
processXml: LINEAR_BPMN,
activities: new Map([['taskA', activityA]]),
gateways: new Map(),
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0.2 },
}
const result = runSimulation(input)
expect(result.totalDirectCost).toBe(100)
expect(result.totalIndirectCost).toBeCloseTo(20)
expect(result.totalCost).toBeCloseTo(120)
})
it('incluye costo de recursos asignados', () => {
const resource = makeResource('res1', 'Analista', 60) // $60/hora
const activityA = makeActivity('taskA', 'Tarea A', 0, 60, [
{ resourceId: 'res1', utilizationPercent: 1.0 }, // 1 hora completa
])
const input: SimulationInput = {
processXml: LINEAR_BPMN,
activities: new Map([['taskA', activityA]]),
gateways: new Map(),
resources: new Map([['res1', resource]]),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
}
const result = runSimulation(input)
expect(result.totalDirectCost).toBeCloseTo(60)
})
it('propaga probabilidad 0.7/0.3 en gateway XOR', () => {
const actA = makeActivity('taskA', 'Evaluar', 100, 30)
const actB = makeActivity('taskB', 'Camino Si', 200, 60)
const actC = makeActivity('taskC', 'Camino No', 100, 30)
const gw: GatewayConfig = {
id: 'gw-1',
processId: 'proc2',
bpmnElementId: 'gw1',
gatewayType: 'exclusive',
branches: [
{ flowId: 'f3', targetElementId: 'taskB', probability: 0.7 },
{ flowId: 'f4', targetElementId: 'taskC', probability: 0.3 },
],
}
const input: SimulationInput = {
processXml: XOR_BPMN,
activities: new Map([
['taskA', actA],
['taskB', actB],
['taskC', actC],
]),
gateways: new Map([['gw1', gw]]),
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
}
const result = runSimulation(input)
const bResult = result.perActivity.find((a) => a.bpmnElementId === 'taskB')
const cResult = result.perActivity.find((a) => a.bpmnElementId === 'taskC')
expect(bResult?.executionProbability).toBeCloseTo(0.7, 1)
expect(cResult?.executionProbability).toBeCloseTo(0.3, 1)
// Costo esperado de B = 200 * 0.7 = 140
expect(bResult?.expectedDirectCost).toBeCloseTo(140)
// Costo esperado de C = 100 * 0.3 = 30
expect(cResult?.expectedDirectCost).toBeCloseTo(30)
})
it('ordena perActivity por costo total descendente', () => {
const actA = makeActivity('taskA', 'Tarea A', 50, 10)
const actB = makeActivity('taskB', 'Tarea B', 500, 60)
const input: SimulationInput = {
processXml: LINEAR_BPMN,
activities: new Map([
['taskA', actA],
['taskB', actB],
]),
gateways: new Map(),
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0 },
}
const result = runSimulation(input)
expect(result.perActivity[0].bpmnElementId).toBe('taskB')
expect(result.perActivity[1].bpmnElementId).toBe('taskA')
})
})

View File

@@ -0,0 +1,418 @@
/**
* E2E: Verifica que el export PDF y CSV funcionen de extremo a extremo.
*
* Por cada BPMN: importa → simula → descarga PDF y CSV → verifica tamaño y contenido.
* El test de acentos verifica que los caracteres UTF-8 se preserven en el PDF.
*
* Ejecutar: npx playwright test
*/
import { test, expect } from '@playwright/test'
import { readFileSync, mkdirSync } 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 })
})
// ─── Helper: parsea texto de un buffer PDF con pdf-parse v2 ──────────────────
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: importa un BPMN, simula y llega al reporte ──────────────────────
async function importAndSimulate(page: import('@playwright/test').Page, bpmnFile: string) {
await page.goto('/')
// Subir el BPMN vía el input oculto (equivalente a drag-and-drop programático)
const fileInput = page.locator('#bpmn-file-input')
await fileInput.setInputFiles(resolve(BPMN_DIR, bpmnFile))
// Esperar navegación al workspace
await page.waitForURL(/\/workspace\//, { timeout: 15_000 })
// Esperar a que desaparezca el spinner de carga (workspace renderiza el botón Simular)
await page.waitForSelector('button:has-text("Simular")', { timeout: 15_000 })
// Tiempo adicional para que Dexie cargue gateways y actividades en la store
await page.waitForTimeout(1_500)
// Verificar que el botón no está deshabilitado y hacer clic
const simularBtn = page.getByRole('button', { name: 'Simular' })
await expect(simularBtn).not.toBeDisabled({ timeout: 5_000 })
await simularBtn.click()
// Esperar navegación al reporte
await page.waitForURL(/\/report\//, { timeout: 20_000 })
// Esperar a que el reporte cargue y el botón de export sea visible
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 30_000 })
// Esperar a que bpmn-js en HeatmapCanvas haya renderizado el diagrama.
// .djs-group es creado por bpmn-js después de importXML() — confirma render real.
await page.waitForSelector('.bpmn-container .djs-group', { timeout: 30_000 })
}
// ─── Casos por BPMN ──────────────────────────────────────────────────────────
const CASES = [
{
file: 'simple-linear.bpmn',
slug: 'simple-linear',
processNameFragment: 'simple-linear',
currency: 'USD',
},
{
file: 'medium-with-gateways.bpmn',
slug: 'medium-with-gateways',
processNameFragment: 'medium-with-gateways',
currency: 'USD',
},
{
file: 'complex-with-loop.bpmn',
slug: 'complex-with-loop',
processNameFragment: 'complex-with-loop',
currency: 'USD',
},
]
for (const c of CASES) {
test(`Export PDF + CSV — ${c.file}`, async ({ page }) => {
await importAndSimulate(page, c.file)
// ── PDF ─────────────────────────────────────────────────────────────────
const pdfPath = resolve(OUTPUT_DIR, `${c.slug}.pdf`)
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
await page.getByRole('button', { name: 'Exportar PDF' }).click()
const dl = await pdfDownload
await dl.saveAs(pdfPath)
// Esperar a que el estado "Generando…" desaparezca
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 30_000 })
// Verificar tamaño
const pdfBuffer = readFileSync(pdfPath)
const pdfBytes = pdfBuffer.length
console.log(`\n📄 ${c.file} — PDF: ${pdfBytes} bytes (${(pdfBytes / 1024).toFixed(1)} KB)`)
// PDF mínimo: debe tener al menos algo de contenido
expect(pdfBytes, `PDF de ${c.file} debe ser > 40 KB`).toBeGreaterThan(40_000)
expect(pdfBytes, `PDF de ${c.file} debe ser < 2 MB`).toBeLessThan(2_000_000)
// El heatmap JPEG debe estar embebido en el PDF (marker SOI: FF D8 FF).
// Sin actividades con costos reales el JPEG es pequeño (~32 KB, color neutro #cbd5e1)
// pero SIEMPRE debe estar presente cuando el canvas se renderizó correctamente.
const jpegMarker = Buffer.from([0xff, 0xd8, 0xff])
const hasJpeg = pdfBuffer.includes(jpegMarker)
console.log(` Heatmap JPEG embebido: ${hasJpeg}`)
expect(hasJpeg, 'PDF generado sin heatmap embebido — la race condition de captureImage no fue resuelta').toBe(true)
// Verificar contenido
const { numpages, text } = await parsePdf(pdfBuffer)
console.log(` Páginas: ${numpages}`)
console.log(` Texto (primeros 200 chars): ${text.slice(0, 200).replace(/\n/g, ' ')}`)
expect(numpages, 'PDF debe tener al menos 2 páginas').toBeGreaterThanOrEqual(2)
// El KPI de costo aparece en mayúsculas en el PDF: "COSTO TOTAL ESPERADO"
expect(text, 'PDF debe contener "COSTO TOTAL"').toContain('COSTO TOTAL')
expect(text, `PDF debe contener la moneda "${c.currency}"`).toContain(c.currency)
expect(text, 'PDF debe contener el nombre del proceso').toContain(c.processNameFragment)
// ── CSV ─────────────────────────────────────────────────────────────────
const csvPath = resolve(OUTPUT_DIR, `${c.slug}.csv`)
const csvDownload = page.waitForEvent('download')
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`)
expect(csvBytes, `CSV de ${c.file} debe ser > 100 bytes`).toBeGreaterThan(100)
const csvText = csvBuffer.toString('utf-8')
expect(csvText, 'CSV debe tener BOM UTF-8').toMatch(/^/)
expect(csvText, 'CSV debe contener "Costo total"').toContain('Costo total')
expect(csvText, 'CSV debe contener la moneda').toContain(c.currency)
})
}
// ─── Test especial: acentos y ñ ───────────────────────────────────────────────
test('Export PDF — acentos y ñ se preservan en el PDF (accent-test.bpmn)', async ({ page }) => {
await importAndSimulate(page, 'accent-test.bpmn')
const pdfPath = resolve(OUTPUT_DIR, 'accent-test.pdf')
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
await page.getByRole('button', { name: 'Exportar PDF' }).click()
const dl = await pdfDownload
await dl.saveAs(pdfPath)
const pdfBuffer = readFileSync(pdfPath)
const pdfBytes = pdfBuffer.length
console.log(`\n🔤 accent-test.bpmn — PDF: ${pdfBytes} bytes (${(pdfBytes / 1024).toFixed(1)} KB)`)
expect(pdfBytes, 'PDF debe ser > 40 KB').toBeGreaterThan(40_000)
const jpegMarker = Buffer.from([0xff, 0xd8, 0xff])
const hasJpeg = pdfBuffer.includes(jpegMarker)
console.log(` Heatmap JPEG embebido: ${hasJpeg}`)
expect(hasJpeg, 'PDF generado sin heatmap embebido — la race condition de captureImage no fue resuelta').toBe(true)
const { numpages, text } = await parsePdf(pdfBuffer)
console.log(` Páginas: ${numpages}`)
console.log(` Texto completo:\n${text}`)
expect(numpages).toBeGreaterThanOrEqual(2)
// Verificar que los caracteres acentuados se preservan
expect(text, 'PDF debe contener "Análisis de procesos contables"')
.toContain('Análisis de procesos contables')
expect(text, 'PDF debe contener "Validación de información"')
.toContain('Validación de información')
expect(text, 'PDF debe contener "Inspección visual y funcional"')
.toContain('Inspección visual y funcional')
expect(text, 'PDF debe contener "Diseño de campañas"')
.toContain('Diseño de campañas')
// CSV del accent-test
await page.waitForSelector('button:has-text("Exportar CSV")', { timeout: 10_000 })
const csvPath = resolve(OUTPUT_DIR, 'accent-test.csv')
const csvDownload = page.waitForEvent('download')
await page.getByRole('button', { name: 'Exportar CSV' }).click()
const dlCsv = await csvDownload
await dlCsv.saveAs(csvPath)
const csvText = readFileSync(csvPath, 'utf-8')
console.log(` CSV (primeros 400 chars): ${csvText.slice(0, 400)}`)
expect(csvText).toContain('Análisis de procesos contables')
expect(csvText).toContain('Validación de información')
expect(csvText).toContain('Inspección visual y funcional')
expect(csvText).toContain('Diseño de campañas')
})
// ─── Helpers para extracción y análisis de JPEG ───────────────────────────────
/**
* Extrae el primer stream JPEG (DCTDecode) del PDF.
* Intenta con pdf-lib; si falla, usa búsqueda binaria de marcador SOI/EOI.
*/
async function extractJpegFromPdf(pdfBuf: Buffer): Promise<Buffer> {
// Intento 1: pdf-lib (semántico, correcto)
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 {
// pdf-lib no pudo parsear — caer a método binario
}
// Intento 2: búsqueda binaria de marcador JPEG SOI (FF D8 FF)
const soi = Buffer.from([0xff, 0xd8, 0xff])
const start = pdfBuf.indexOf(soi)
if (start === -1) throw new Error('No se encontró ningún 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" después del JPEG')
// Buscar EOI (FF D9) retrocediendo desde '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 extraído muy pequeño: ${jpeg.length} bytes`)
return jpeg
}
/**
* Cuenta píxeles por categoría de color en un buffer JPEG.
* Umbrales calibrados para los colores del heatmap con opacity 0.82 sobre #f8fafc.
*
* Verde (#10b981 + bg) → RGB ≈ (58, 197, 152) → G > R·1.4, G > 120
* Ámbar (#f59e0b + bg) → RGB ≈ (246, 175, 54) → R > 180, G > 120, B < 120, R/G < 1.8
* Rojo (#ef4444 + bg) → RGB ≈ (241, 101, 101) → R > G·1.8, R > B·1.8, R > 180
*/
async function analyzeJpegColors(jpegBuf: Buffer): Promise<{
greenPx: number; amberPx: number; redPx: number; total: number
}> {
const { data, info } = await sharp(jpegBuf).raw().toBuffer({ resolveWithObject: true })
const ch = info.channels // 3 para JPEG (RGB)
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++ // Verde
} else if (r > 180 && g > 120 && b < 120 && r > g && r < g * 1.8) {
amberPx++ // Ámbar / amarillo cálido
} else if (r > g * 1.8 && r > b * 1.8 && r > 180) {
redPx++ // Rojo
}
}
return { greenPx, amberPx, redPx, total: data.length / ch }
}
// ─── Test: gradiente real con costos dispares ─────────────────────────────────
test('Heatmap con gradiente real — medium-with-gateways.bpmn', async ({ page }) => {
// Costos dispares por bpmnElementId para forzar varianza significativa.
// Probabilidades de ejecución del proceso: task_recv/valid/analisis/archivo = 1.0,
// los demás ≈ 0.5. Se asignan costos para que:
// task_recv → expectedCost $10.000 → costo más alto → ROJO
// task_analisis → expectedCost $5.000 → mitad del max → ÁMBAR (t ≈ 0.5)
// resto → expectedCost < $500 → costos bajos → VERDE
const COST_BY_ELEMENT: Record<string, number> = {
task_recv: 10_000, // prob 1.0 → expected $10.000 → ROJO
task_valid: 200, // prob 1.0 → expected $200
task_score: 200, // prob 0.5 → expected $100
task_pedir_docs: 200, // prob 0.5 → expected $100
task_bureau: 200, // prob 0.5 → expected $100
task_empleador: 200, // prob 0.5 → expected $100
task_analisis: 5_000, // prob 1.0 → expected $5.000 → ÁMBAR
task_aprobar: 200, // prob 0.5 → expected $100
task_rechazar: 200, // prob 0.5 → expected $100
task_desembolso: 200, // prob 0.5 → expected $100
task_archivo: 200, // prob 1.0 → expected $200 → VERDE (mínimo)
}
// ── 1. Importar BPMN y navegar al workspace ─────────────────────────────────
await page.goto('/')
await page.locator('#bpmn-file-input').setInputFiles(resolve(BPMN_DIR, 'medium-with-gateways.bpmn'))
await page.waitForURL(/\/workspace\//)
const processId = page.url().split('/workspace/')[1]
// ── 2. Inyectar costos dispares directamente en IndexedDB (Dexie) ───────────
await page.evaluate(async ({ procId, costs }) => {
const DB_NAME = 'ProcessCostPlatform'
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const req = indexedDB.open(DB_NAME)
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
const activities = await new Promise<any[]>((resolve, reject) => {
const tx = db.transaction('activities', 'readonly')
const index = tx.objectStore('activities').index('processId')
const req = index.getAll(procId)
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('activities', 'readwrite')
const store = tx.objectStore('activities')
let pending = activities.length
if (pending === 0) { resolve(); return }
for (const act of activities) {
const req = store.put({
...act,
directCostFixed: (costs as Record<string, number>)[act.bpmnElementId] ?? 200,
executionTimeMinutes: 60,
})
req.onsuccess = () => { if (--pending === 0) resolve() }
req.onerror = () => reject(req.error)
}
})
db.close()
}, { procId: processId, costs: COST_BY_ELEMENT })
// ── 3. Recargar workspace para que Zustand relea desde Dexie ────────────────
await page.reload()
await page.waitForSelector('button:has-text("Simular")', { timeout: 15_000 })
await page.waitForTimeout(1_500) // esperar carga de store
// ── 4. Simular y navegar al reporte ────────────────────────────────────────
const simularBtn = page.getByRole('button', { name: 'Simular' })
await expect(simularBtn).not.toBeDisabled({ timeout: 5_000 })
await simularBtn.click()
await page.waitForURL(/\/report\//, { timeout: 20_000 })
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 20_000 })
// ── 5. Esperar a que el heatmap tenga colores reales (no neutro #cbd5e1) ────
// Usamos style.fill (inline CSS) porque applyHeatmapToViewer ahora lo usa
// en vez de setAttribute (para garantizar mayor especificidad que bpmn-js CSS).
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 })
// Capturar estado de los fills para diagnóstico
const fillsSnapshot = await page.evaluate(() =>
Array.from(document.querySelectorAll('.bpmn-container .djs-visual rect'))
.map((r) => (r as HTMLElement).style.fill || r.getAttribute('fill'))
)
console.log('\n🎨 Fills del heatmap:', fillsSnapshot)
// Screenshot para evidencia visual
await page.screenshot({ path: resolve(OUTPUT_DIR, 'medium-with-gradient-report.png'), fullPage: false })
// ── 6. Exportar PDF ─────────────────────────────────────────────────────────
const pdfPath = resolve(OUTPUT_DIR, 'medium-with-gradient.pdf')
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
await page.getByRole('button', { name: 'Exportar PDF' }).click()
const dl = await pdfDownload
await dl.saveAs(pdfPath)
// ── 7. Verificar tamaño ─────────────────────────────────────────────────────
const pdfBuf = readFileSync(pdfPath)
const pdfKB = (pdfBuf.length / 1024).toFixed(1)
console.log(`\n📄 medium-with-gradient.pdf: ${pdfBuf.length} bytes (${pdfKB} KB)`)
// JPEG de colores sólidos comprime casi igual que el gris neutro.
// El umbral mínimo es > 50 KB (PDF sin JPEG pesa ~28 KB; con JPEG ~60-80 KB).
expect(pdfBuf.length, 'PDF con gradiente real debe ser > 50 KB').toBeGreaterThan(50_000)
// ── 8. Extraer JPEG y analizar píxeles ──────────────────────────────────────
const jpegBuf = await extractJpegFromPdf(pdfBuf)
console.log(` JPEG extraído: ${jpegBuf.length} bytes`)
const { greenPx, amberPx, redPx, total } = await analyzeJpegColors(jpegBuf)
console.log(` Total píxeles: ${total.toLocaleString()}`)
console.log(` 🟢 Verde: ${greenPx.toLocaleString()} px`)
console.log(` 🟡 Ámbar: ${amberPx.toLocaleString()} px`)
console.log(` 🔴 Rojo: ${redPx.toLocaleString()} px`)
const MIN_PX = 100
expect(greenPx, `Píxeles verdes en heatmap (≥ ${MIN_PX}): ${greenPx}`).toBeGreaterThanOrEqual(MIN_PX)
expect(amberPx, `Píxeles ámbar en heatmap (≥ ${MIN_PX}): ${amberPx}`).toBeGreaterThanOrEqual(MIN_PX)
expect(redPx, `Píxeles rojos en heatmap (≥ ${MIN_PX}): ${redPx}`).toBeGreaterThanOrEqual(MIN_PX)
})

View File

@@ -0,0 +1,91 @@
/**
* Smoke test de producción — verifica el flujo completo en la URL live.
* Usa el proceso de ejemplo "Aprobación de crédito" (carga via fetch desde el CDN).
*
* Ejecutar: npx playwright test --config=playwright.prod.config.ts
*/
import { test, expect } from '@playwright/test'
import { readFileSync, mkdirSync } from 'fs'
import { resolve } from 'path'
import { fileURLToPath } from 'url'
const __dirname = fileURLToPath(new URL('.', import.meta.url))
const OUTPUT_DIR = resolve(__dirname, '__output__')
test.beforeAll(() => {
mkdirSync(OUTPUT_DIR, { recursive: true })
})
test('prod-smoke — import → simulate → export PDF', async ({ page }) => {
// ── 1. Landing page carga correctamente ──────────────────────────────────
await page.goto('/')
await expect(page).toHaveTitle(/Process Cost Platform/)
// Verificar heading principal
await expect(page.getByText('Cuantificá el costo operativo')).toBeVisible({ timeout: 10_000 })
// Verificar que los 3 procesos de ejemplo están presentes
await expect(page.getByText('Aprobación de crédito')).toBeVisible()
await expect(page.getByText('Proceso lineal simple')).toBeVisible()
await expect(page.getByText('Control de calidad con revisión')).toBeVisible()
// ── 2. Cargar proceso de ejemplo "Proceso lineal simple" ──────────────────
await page.getByText('Proceso lineal simple').click()
// Esperar navegación al workspace
await page.waitForURL(/\/workspace\//, { timeout: 30_000 })
// ── 3. Workspace: diagrama BPMN + botón Simular ───────────────────────────
await page.waitForSelector('button:has-text("Simular")', { timeout: 20_000 })
await page.waitForTimeout(1_500)
// Nombre del proceso visible en la topbar
await expect(page.getByText('simple-linear')).toBeVisible()
const simularBtn = page.getByRole('button', { name: 'Simular' })
await expect(simularBtn).not.toBeDisabled({ timeout: 5_000 })
await simularBtn.click()
// ── 4. Reporte carga ──────────────────────────────────────────────────────
await page.waitForURL(/\/report\//, { timeout: 30_000 })
await page.waitForSelector('button:has-text("Exportar PDF")', { timeout: 30_000 })
await page.waitForSelector('.bpmn-container .djs-group', { timeout: 30_000 })
// KPIs visibles
await expect(page.getByText('COSTO TOTAL ESPERADO')).toBeVisible()
// ── 5. Export PDF ─────────────────────────────────────────────────────────
const pdfPath = resolve(OUTPUT_DIR, 'prod-smoke.pdf')
const pdfDownload = page.waitForEvent('download', { timeout: 90_000 })
await page.getByRole('button', { name: 'Exportar PDF' }).click()
const dl = await pdfDownload
await dl.saveAs(pdfPath)
const pdfBuffer = readFileSync(pdfPath)
const pdfKB = (pdfBuffer.length / 1024).toFixed(1)
console.log(`\n📄 prod-smoke.pdf: ${pdfBuffer.length} bytes (${pdfKB} KB)`)
expect(pdfBuffer.length, 'PDF debe ser > 40 KB').toBeGreaterThan(40_000)
expect(pdfBuffer.length, 'PDF debe ser < 2 MB').toBeLessThan(2_000_000)
// JPEG heatmap embebido
const jpegMarker = Buffer.from([0xff, 0xd8, 0xff])
const hasJpeg = pdfBuffer.includes(jpegMarker)
console.log(` Heatmap JPEG embebido: ${hasJpeg}`)
expect(hasJpeg, 'PDF de prod debe tener heatmap embebido').toBe(true)
// ── 6. Export CSV ─────────────────────────────────────────────────────────
const csvPath = resolve(OUTPUT_DIR, 'prod-smoke.csv')
const csvDownload = page.waitForEvent('download')
await page.getByRole('button', { name: 'Exportar CSV' }).click()
const dlCsv = await csvDownload
await dlCsv.saveAs(csvPath)
const csvText = readFileSync(csvPath, 'utf-8')
expect(csvText, 'CSV debe tener BOM UTF-8').toMatch(/^/)
expect(csvText, 'CSV debe contener encabezado').toContain('Costo total')
console.log(`\n✅ Prod smoke PASSED — https://process-cost-platform.pages.dev`)
console.log(` PDF: ${pdfKB} KB | CSV: ${csvText.length} chars`)
})

View File

@@ -0,0 +1,85 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`buildCostCompositionOption — estructura del objeto ECharts > snapshot: estructura completa del option del donut 1`] = `
{
"graphic": [
{
"left": "center",
"style": {
"fill": "#1e293b",
"fontFamily": "JetBrains Mono, monospace",
"fontSize": 13,
"fontWeight": "bold",
"text": "USD 1.200,00",
"textAlign": "center",
},
"top": "38%",
"type": "text",
},
],
"legend": {
"bottom": "2%",
"itemHeight": 12,
"itemWidth": 12,
"left": "center",
"textStyle": {
"color": "#475569",
"fontSize": 11,
},
},
"series": [
{
"avoidLabelOverlap": true,
"center": [
"50%",
"44%",
],
"data": [
{
"itemStyle": {
"color": "#3b82f6",
},
"name": "Costo directo",
"value": 1000,
},
{
"itemStyle": {
"color": "#94a3b8",
},
"name": "Costo indirecto (overhead)",
"value": 200,
},
],
"emphasis": {
"label": {
"fontSize": 13,
"fontWeight": "bold",
},
},
"itemStyle": {
"borderColor": "#fff",
"borderRadius": 4,
"borderWidth": 2,
},
"label": {
"fontSize": 11,
"formatter": [Function],
"show": true,
},
"labelLine": {
"length": 10,
"length2": 8,
},
"radius": [
"48%",
"68%",
],
"type": "pie",
},
],
"tooltip": {
"formatter": [Function],
"trigger": "item",
},
}
`;

View File

@@ -0,0 +1,409 @@
/**
* Tests de renderizado DOM de los componentes del reporte.
* bpmn-js y echarts-for-react se mockean (requieren browser/canvas reales).
* useReportData se mockea para inyectar datos controlados.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
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) }
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} />
),
}))
// ─── Fixtures ─────────────────────────────────────────────────────────────────
import type { ActivitySimResult, SimulationResult } from '@/domain/types'
const mockActivities: ActivitySimResult[] = [
{
activityId: 'a1', bpmnElementId: 'task1', activityName: 'Recibir solicitud',
expectedDirectCost: 500, expectedIndirectCost: 100, expectedTotalCost: 600,
percentOfTotal: 60, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 30, resourceCostBreakdown: [],
},
{
activityId: 'a2', bpmnElementId: 'task2', activityName: 'Procesar pedido',
expectedDirectCost: 250, expectedIndirectCost: 50, expectedTotalCost: 300,
percentOfTotal: 30, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 60, resourceCostBreakdown: [],
},
{
activityId: 'a3', bpmnElementId: 'task3', activityName: 'Notificar cliente',
expectedDirectCost: 83, expectedIndirectCost: 17, expectedTotalCost: 100,
percentOfTotal: 10, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 15, resourceCostBreakdown: [],
},
]
const mockResult: SimulationResult = {
totalCost: 1000,
totalDirectCost: 833,
totalIndirectCost: 167,
totalTimeMinutes: 105,
perActivity: mockActivities,
perResource: [],
warnings: [],
}
// ─── KpiBar ───────────────────────────────────────────────────────────────────
import { KpiBar } from '@/features/report/KpiBar'
describe('KpiBar', () => {
it('renderiza exactamente 4 KPI cards', () => {
render(<KpiBar result={mockResult} currency="USD" />)
const cards = screen.getAllByTestId('kpi-card')
expect(cards).toHaveLength(4)
})
it('la primera card muestra el costo total formateado', () => {
render(<KpiBar result={mockResult} currency="USD" />)
// El valor $1,000.00 debe aparecer en algún lugar del DOM
const cards = screen.getAllByTestId('kpi-card')
const allText = cards.map((c) => c.textContent).join(' ')
expect(allText).toMatch(/1[.,]000/)
})
it('con currency PYG: muestra el valor sin crash', () => {
expect(() => render(<KpiBar result={mockResult} currency="PYG" />)).not.toThrow()
const cards = screen.getAllByTestId('kpi-card')
expect(cards).toHaveLength(4)
})
it('tiempo total aparece formateado en minutos/horas', () => {
render(<KpiBar result={mockResult} currency="USD" />)
// 105 min = 1h 45min
const text = document.body.textContent ?? ''
expect(text).toMatch(/1h|105/)
})
it('cantidad de actividades es visible', () => {
render(<KpiBar result={mockResult} currency="USD" />)
const text = document.body.textContent ?? ''
expect(text).toMatch(/3/)
})
})
// ─── WarningsBanner ───────────────────────────────────────────────────────────
import { WarningsBanner } from '@/features/report/WarningsBanner'
describe('WarningsBanner', () => {
it('no renderiza nada cuando no hay warnings', () => {
const { container } = render(<WarningsBanner warnings={[]} />)
expect(container.firstChild).toBeNull()
})
it('renderiza el banner cuando hay warnings', () => {
render(<WarningsBanner warnings={['Loop detectado en task1']} />)
expect(screen.getByText(/Loop detectado/)).toBeInTheDocument()
})
it('con múltiples warnings: muestra todos', () => {
render(<WarningsBanner warnings={['Warning 1', 'Warning 2', 'Warning 3']} />)
expect(screen.getByText(/Warning 1/)).toBeInTheDocument()
expect(screen.getByText(/Warning 2/)).toBeInTheDocument()
expect(screen.getByText(/Warning 3/)).toBeInTheDocument()
})
it('warning de loop: muestra nota de impacto en resultados', () => {
render(<WarningsBanner warnings={['Loop detectado en "Inspección" forma un ciclo']} />)
expect(screen.getByText(/Impacto en los resultados/i)).toBeInTheDocument()
})
it('warning sin loop: NO muestra nota de impacto', () => {
render(<WarningsBanner warnings={['Actividad "X" no encontrada en diagrama']} />)
expect(screen.queryByText(/Impacto en los resultados/i)).not.toBeInTheDocument()
})
})
// ─── ActivitiesTable ─────────────────────────────────────────────────────────
import { ActivitiesTable } from '@/features/report/ActivitiesTable'
describe('ActivitiesTable', () => {
it('renderiza exactamente N filas donde N = perActivity.length', () => {
render(
<ActivitiesTable
perActivity={mockActivities}
mode="relative"
currency="USD"
highlightedId={null}
onRowClick={vi.fn()}
/>
)
// getAllByRole('row') incluye el header row, entonces total = N + 1
const rows = screen.getAllByRole('row')
expect(rows).toHaveLength(mockActivities.length + 1)
})
it('los nombres de las actividades aparecen en la tabla', () => {
render(
<ActivitiesTable
perActivity={mockActivities}
mode="relative"
currency="USD"
highlightedId={null}
onRowClick={vi.fn()}
/>
)
expect(screen.getByText('Recibir solicitud')).toBeInTheDocument()
expect(screen.getByText('Procesar pedido')).toBeInTheDocument()
expect(screen.getByText('Notificar cliente')).toBeInTheDocument()
})
it('click en fila dispara onRowClick con el bpmnElementId correcto', () => {
const onRowClick = vi.fn()
render(
<ActivitiesTable
perActivity={mockActivities}
mode="relative"
currency="USD"
highlightedId={null}
onRowClick={onRowClick}
/>
)
const row = screen.getByText('Procesar pedido').closest('tr')!
fireEvent.click(row)
expect(onRowClick).toHaveBeenCalledWith('task2')
expect(onRowClick).toHaveBeenCalledTimes(1)
})
it('fila resaltada tiene clase/estado visual distinto', () => {
render(
<ActivitiesTable
perActivity={mockActivities}
mode="relative"
currency="USD"
highlightedId="task1"
onRowClick={vi.fn()}
/>
)
const highlightedRow = screen.getByText('Recibir solicitud').closest('tr')!
// Verificamos que el atributo data-state='selected' está presente en la fila resaltada
expect(highlightedRow).toHaveAttribute('data-state', 'selected')
})
it('tabla sin actividades: renderiza solo el header (0 data rows)', () => {
render(
<ActivitiesTable
perActivity={[]}
mode="relative"
currency="USD"
highlightedId={null}
onRowClick={vi.fn()}
/>
)
const rows = screen.getAllByRole('row')
expect(rows).toHaveLength(1) // solo el header
})
})
// ─── MethodologyFooter ────────────────────────────────────────────────────────
import { MethodologyFooter } from '@/features/report/MethodologyFooter'
describe('MethodologyFooter', () => {
it('renderiza sin crash con un proceso válido', () => {
const proc = {
id: 'p1', name: 'Proceso Test', clientName: '',
bpmnXml: '<def/>', currency: 'USD',
overheadPercentage: 0.2, createdAt: 0, updatedAt: 0,
}
expect(() => render(<MethodologyFooter process={proc} />)).not.toThrow()
})
it('muestra el overhead configurado', () => {
const proc = {
id: 'p1', name: 'Proc', clientName: '',
bpmnXml: '', currency: 'USD',
overheadPercentage: 0.25, createdAt: 0, updatedAt: 0,
}
render(<MethodologyFooter process={proc} />)
const text = document.body.textContent ?? ''
expect(text).toMatch(/25%/)
})
it('contiene las palabras clave de la metodología', () => {
const proc = {
id: 'p1', name: 'Proc', clientName: '',
bpmnXml: '', currency: 'USD',
overheadPercentage: 0.2, createdAt: 0, updatedAt: 0,
}
render(<MethodologyFooter process={proc} />)
const text = document.body.textContent ?? ''
expect(text).toMatch(/Nota metodológica|metodol/i)
expect(text).toMatch(/probabilidad/i)
})
})
// ─── ReportPage — empty state (sin simulación) ─────────────────────────────────
vi.mock('@/features/report/useReportData', () => ({
useReportData: vi.fn(),
}))
import { useReportData } from '@/features/report/useReportData'
import { ReportPage } from '@/features/report/ReportPage'
// Mock de TanStack Router para tests de ReportPage
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()),
}
})
describe('ReportPage — empty state (sin simulación)', () => {
beforeEach(() => {
vi.mocked(useReportData).mockReturnValue({
process: null,
simulation: null,
activities: [],
resources: [],
isLoading: false,
error: 'Sin simulación disponible. Volvé al workspace y ejecutá "Simular" primero.',
})
})
it('muestra mensaje de error cuando no hay simulación', () => {
render(<ReportPage />)
expect(screen.getByText(/Sin simulación disponible/i)).toBeInTheDocument()
})
it('muestra botón para ir al workspace', () => {
render(<ReportPage />)
const btn = screen.getByText(/Volver a configurar y simular/i)
expect(btn).toBeInTheDocument()
})
})
describe('ReportPage — loading state', () => {
beforeEach(() => {
vi.mocked(useReportData).mockReturnValue({
process: null,
simulation: null,
activities: [],
resources: [],
isLoading: true,
error: null,
})
})
it('muestra skeleton de carga (no pantalla en blanco)', () => {
render(<ReportPage />)
// El skeleton usa animate-pulse en el contenedor
const skeleton = document.querySelector('.animate-pulse')
expect(skeleton).not.toBeNull()
})
})
describe('ReportPage — con datos completos', () => {
const mockProcess = {
id: 'test-proc-id',
name: 'Proceso de Aprobación de Crédito',
clientName: 'Banco ABC',
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,
createdAt: Date.now() - 1000 * 60 * 30,
updatedAt: Date.now() - 1000 * 60 * 30,
}
const mockSimulation = {
id: 'sim-1',
processId: 'test-proc-id',
executedAt: Date.now() - 1000 * 60 * 10,
result: mockResult,
}
beforeEach(() => {
vi.mocked(useReportData).mockReturnValue({
process: mockProcess,
simulation: mockSimulation,
activities: [],
resources: [],
isLoading: false,
error: null,
})
})
it('renderiza el h1 con el nombre del proceso', () => {
render(<ReportPage />)
const h1 = screen.getByRole('heading', { level: 1 })
expect(h1).toHaveTextContent('Proceso de Aprobación de Crédito')
})
it('hay exactamente 4 KPI cards', () => {
render(<ReportPage />)
const cards = screen.getAllByTestId('kpi-card')
expect(cards).toHaveLength(4)
})
it('hay al menos 2 gráficos ECharts (TopActivities + Donut siempre; CostByResource solo con recursos)', () => {
render(<ReportPage />)
const charts = screen.getAllByTestId('echarts-chart')
// TopActivities y CostComposition siempre renderizan.
// CostByResource muestra empty-state cuando resources=[] y sin breakdown.
expect(charts.length).toBeGreaterThanOrEqual(2)
})
it('la tabla tiene N+1 rows (N actividades + 1 header)', () => {
render(<ReportPage />)
const rows = screen.getAllByRole('row')
expect(rows).toHaveLength(mockActivities.length + 1)
})
it('el nombre del cliente aparece en el reporte', () => {
render(<ReportPage />)
expect(screen.getByText(/Banco ABC/)).toBeInTheDocument()
})
it('el timestamp de simulación es visible', () => {
render(<ReportPage />)
const text = document.body.textContent ?? ''
// "Simulado el" debe aparecer en el header
expect(text).toMatch(/Simulado el/i)
})
it('no hay banner de warnings cuando no hay warnings', () => {
render(<ReportPage />)
expect(screen.queryByText(/Advertencia/i)).not.toBeInTheDocument()
})
it('con warnings: el banner aparece', () => {
vi.mocked(useReportData).mockReturnValue({
process: mockProcess,
simulation: {
...mockSimulation,
result: { ...mockResult, warnings: ['Loop detectado en "Inspección"'] },
},
activities: [],
resources: [],
isLoading: false,
error: null,
})
render(<ReportPage />)
expect(screen.getByText(/Loop detectado/)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,293 @@
/**
* Tests de derivación de datos del reporte.
* Para cada uno de los 3 sample BPMNs, simula con costos predefinidos
* y verifica invariantes matemáticos y de presentación.
*/
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 { buildCostCompositionOption, buildTopActivitiesOption, aggregateResourceCosts } from '@/lib/chart-options'
import { formatCurrency } from '@/lib/format'
import { bpmnTypeToGatewayType } from '@/domain/types'
import type { Activity, GatewayConfig, Resource, SimulationInput } from '@/domain/types'
function loadBpmn(name: string): string {
return readFileSync(resolve(__dirname, '../../../public/sample-processes', name), 'utf-8')
}
function buildSimInput(xml: string, directCost: number, overhead: number, currency = 'USD'): SimulationInput {
const graph = parseBpmnXml(xml)
const actEls = extractActivityElements(graph)
const gwEls = extractGatewayElements(graph)
const processId = 'report-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: 30,
assignedResources: [],
}])
)
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<string, Resource>(),
globalSettings: { currency, overheadPercentage: overhead },
}
}
// ─── INVARIANTES MATEMÁTICOS ─────────────────────────────────────────────────
describe('invariantes matemáticos — los 3 BPMNs', () => {
const BPMNS = [
{ name: 'simple-linear.bpmn', overhead: 0.2, label: 'simple-linear' },
{ name: 'medium-with-gateways.bpmn', overhead: 0.15, label: 'medium-gateways' },
{ name: 'complex-with-loop.bpmn', overhead: 0.25, label: 'complex-loop' },
]
for (const { name, overhead, label } of BPMNS) {
describe(label, () => {
const xml = loadBpmn(name)
const result = runSimulation(buildSimInput(xml, 100, overhead))
it('directo + indirecto = total (dentro de tolerancia float)', () => {
expect(result.totalDirectCost + result.totalIndirectCost).toBeCloseTo(result.totalCost, 6)
})
it('overhead indirecto = directCost × overheadPct', () => {
expect(result.totalIndirectCost).toBeCloseTo(result.totalDirectCost * overhead, 6)
})
it('suma de percentOfTotal de todas las actividades ≈ 100%', () => {
const sum = result.perActivity.reduce((s, a) => s + a.percentOfTotal, 0)
expect(sum).toBeCloseTo(100, 1)
})
it('suma de expectedTotalCost de actividades ≈ totalCost', () => {
const sum = result.perActivity.reduce((s, a) => s + a.expectedTotalCost, 0)
expect(sum).toBeCloseTo(result.totalCost, 4)
})
it('perActivity ordenado desc por expectedTotalCost', () => {
for (let i = 0; i < result.perActivity.length - 1; i++) {
expect(result.perActivity[i].expectedTotalCost).toBeGreaterThanOrEqual(
result.perActivity[i + 1].expectedTotalCost
)
}
})
it('cantidad de filas de tabla = cantidad de Activity parseadas', () => {
const graph = parseBpmnXml(xml)
const actEls = extractActivityElements(graph)
expect(result.perActivity).toHaveLength(actEls.length)
})
it('cada actividad: directCost + indirectCost = totalCost (dentro de tolerancia)', () => {
for (const act of result.perActivity) {
expect(act.expectedDirectCost + act.expectedIndirectCost).toBeCloseTo(act.expectedTotalCost, 4)
}
})
it('sin recursos: suma de resourceCostBreakdown = 0 para cada actividad', () => {
for (const act of result.perActivity) {
const resourceSum = act.resourceCostBreakdown.reduce((s, r) => s + r.cost, 0)
expect(resourceSum).toBeCloseTo(0, 6)
}
})
it('totalTimeMinutes > 0 cuando hay actividades con tiempo > 0', () => {
expect(result.totalTimeMinutes).toBeGreaterThan(0)
})
})
}
})
// ─── FORMATO DE MONEDA EN EL REPORTE ─────────────────────────────────────────
describe('formato de moneda en datos del reporte', () => {
it('USD: KPI costo total formateado correctamente ($1,200.00)', () => {
const xml = loadBpmn('simple-linear.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0.2, 'USD'))
const formatted = formatCurrency(result.totalCost, 'USD')
// Simple-linear: 5 actividades × $100 × 1.2 = $600
expect(formatted).toMatch(/600/)
expect(formatted).toMatch(/\$|USD/)
})
it('PYG: KPI costo total formateado correctamente (sin decimales en PYG)', () => {
const xml = loadBpmn('simple-linear.bpmn')
const result = runSimulation(buildSimInput(xml, 100000, 0.2, 'PYG'))
const formatted = formatCurrency(result.totalCost, 'PYG')
expect(formatted).toBeTruthy()
expect(formatted).toMatch(/600/)
})
it('USD vs PYG: misma cantidad produce representaciones distintas', () => {
const amountUsd = formatCurrency(1000, 'USD')
const amountPyg = formatCurrency(1000, 'PYG')
expect(amountUsd).not.toBe(amountPyg)
})
})
// ─── DONUT CHART OPTION — snapshot del objeto ECharts ────────────────────────
describe('buildCostCompositionOption — estructura del objeto ECharts', () => {
it('tiene exactamente 1 serie de tipo pie', () => {
const xml = loadBpmn('simple-linear.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0.2))
const option = buildCostCompositionOption(result, 'USD')
const series = option.series as any[]
expect(series).toHaveLength(1)
expect(series[0].type).toBe('pie')
})
it('con overhead > 0: data tiene 2 items (directo + indirecto)', () => {
const xml = loadBpmn('simple-linear.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0.2))
const option = buildCostCompositionOption(result, 'USD')
const series = option.series as any[]
expect(series[0].data).toHaveLength(2)
expect(series[0].data[0].name).toBe('Costo directo')
expect(series[0].data[1].name).toBe('Costo indirecto (overhead)')
})
it('con overhead = 0: data tiene solo 1 item (solo directo)', () => {
const xml = loadBpmn('simple-linear.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0))
const option = buildCostCompositionOption(result, 'USD')
const series = option.series as any[]
expect(series[0].data).toHaveLength(1)
expect(series[0].data[0].name).toBe('Costo directo')
})
it('los valores de data suman al totalCost', () => {
const xml = loadBpmn('medium-with-gateways.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0.2))
const option = buildCostCompositionOption(result, 'USD')
const series = option.series as any[]
const dataSum = (series[0].data as { value: number }[]).reduce((s, d) => s + d.value, 0)
expect(dataSum).toBeCloseTo(result.totalCost, 4)
})
it('el graphic label contiene el totalCost formateado', () => {
const result = { totalDirectCost: 1000, totalIndirectCost: 200, totalCost: 1200 }
const option = buildCostCompositionOption(result, 'USD')
const graphic = option.graphic as any[]
expect(graphic[0].style.text).toContain('1')
expect(graphic[0].style.text).toContain('200')
})
it('snapshot: estructura completa del option del donut', () => {
const result = { totalDirectCost: 1000, totalIndirectCost: 200, totalCost: 1200 }
const option = buildCostCompositionOption(result, 'USD')
expect(option).toMatchSnapshot()
})
})
// ─── TOP ACTIVITIES CHART — estructura ───────────────────────────────────────
describe('buildTopActivitiesOption — estructura', () => {
it('serie de tipo bar con hasta 10 items', () => {
const xml = loadBpmn('medium-with-gateways.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0.2))
const option = buildTopActivitiesOption(result.perActivity, 'relative', 'USD')
const series = option.series as any[]
expect(series[0].type).toBe('bar')
expect(series[0].data.length).toBeLessThanOrEqual(10)
expect(series[0].data.length).toBeGreaterThan(0)
})
it('la primera barra (actividad más costosa) tiene color del extremo cálido', () => {
const xml = loadBpmn('simple-linear.bpmn')
const result = runSimulation(buildSimInput(xml, 200, 0))
// En modo relativo con varianza real, la 1ra actividad → t=1 → rojo
// En simple-linear todos cuestan igual → NEUTRAL_HEATMAP_COLOR
const option = buildTopActivitiesOption(result.perActivity, 'relative', 'USD')
const series = option.series as any[]
// Verifica que itemStyle.color existe en cada barra
series[0].data.forEach((item: any) => {
expect(item.itemStyle).toBeDefined()
expect(item.itemStyle.color).toMatch(/^#[0-9a-f]{6}$/)
})
})
it('yAxis data contiene los nombres de actividades (truncados)', () => {
const xml = loadBpmn('simple-linear.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0))
const option = buildTopActivitiesOption(result.perActivity, 'relative', 'USD')
const yAxis = option.yAxis as any
expect(yAxis.data).toHaveLength(result.perActivity.length)
// Los nombres deben existir (no vacíos)
yAxis.data.forEach((name: string) => expect(name.length).toBeGreaterThan(0))
})
})
// ─── AGGREGATE RESOURCE COSTS ─────────────────────────────────────────────────
describe('aggregateResourceCosts', () => {
it('sin recursos: activeTypes vacío, totales en cero', () => {
const xml = loadBpmn('simple-linear.bpmn')
const result = runSimulation(buildSimInput(xml, 100, 0))
const { byType, activeTypes } = aggregateResourceCosts(result.perActivity, [])
expect(activeTypes).toHaveLength(0)
expect(Object.keys(byType)).toHaveLength(0)
})
it('con recursos de tipo role: suma correctamente por tipo', () => {
const resource: Resource = {
id: 'r1', processId: 'p1', name: 'Analista', type: 'role', costPerHour: 60,
}
const perActivity = [{
activityId: 'a1', bpmnElementId: 't1', activityName: 'Task', type: 'task' as const,
expectedDirectCost: 60, expectedIndirectCost: 12, expectedTotalCost: 72,
percentOfTotal: 100, executionProbability: 1, expectedExecutions: 1,
executionTimeMinutes: 60,
resourceCostBreakdown: [{ resourceId: 'r1', resourceName: 'Analista', cost: 60 }],
}]
const { byType, activeTypes } = aggregateResourceCosts(perActivity, [resource])
expect(activeTypes).toContain('role')
expect(byType.role).toBeCloseTo(60)
})
})
// ─── EMPTY STATE / ERROR STATE ───────────────────────────────────────────────
describe('estados edge del reporte', () => {
it('resultado con 0 actividades: perActivity vacío → totalCost = 0', () => {
const result = runSimulation({
processXml: `<?xml version="1.0"?><definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL">
<process id="p"><startEvent id="s"><outgoing>f1</outgoing></startEvent>
<endEvent id="e"><incoming>f1</incoming></endEvent>
<sequenceFlow id="f1" sourceRef="s" targetRef="e"/></process></definitions>`,
activities: new Map(),
gateways: new Map(),
resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0.2 },
})
expect(result.perActivity).toHaveLength(0)
expect(result.totalCost).toBe(0)
expect(result.warnings).toHaveLength(0)
})
})

View File

@@ -0,0 +1,251 @@
/**
* Tests permanentes del parser BPMN.
* Protegen contra regresiones cuando se modifique parseBpmnXml,
* extractActivityElements o extractGatewayElements.
*
* Conteos esperados (fijos, derivados del XML de los sample BPMNs):
*
* simple-linear.bpmn
* nodes total : 7 (1 startEvent + 5 tasks + 1 endEvent)
* flows total : 6
* tasks : 5
* gateways : 0
* events : 2
* divergentes : 0
*
* medium-with-gateways.bpmn
* nodes total : 17 (1 start + 11 tasks + 4 gateways + 1 end)
* flows total : 19
* tasks : 11
* gateways : 4 (gw_datos XOR, gw_parallel_split AND, gw_parallel_join AND, gw_decision XOR)
* events : 2
* divergentes : 3 (gw_datos:2, gw_parallel_split:2, gw_decision:2)
*
* complex-with-loop.bpmn
* nodes total : 13 (1 start + 9 tasks + 2 gateways + 1 end)
* flows total : 14
* tasks : 9
* gateways : 2 (gw_resultado XOR 3-salidas, gw_merge XOR convergente)
* events : 2
* divergentes : 1 (gw_resultado:3)
*/
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import {
parseBpmnXml,
extractActivityElements,
extractGatewayElements,
BpmnParseError,
} from '@/domain/bpmn-parser'
import { isTaskNode, isGatewayNode } from '@/domain/types'
function loadSample(name: string): string {
return readFileSync(resolve(__dirname, '../../public/sample-processes', name), 'utf-8')
}
function countByCategory(graph: ReturnType<typeof parseBpmnXml>) {
let tasks = 0, gateways = 0, events = 0, unknown = 0
for (const node of graph.nodes.values()) {
if (isTaskNode(node.type)) tasks++
else if (isGatewayNode(node.type)) gateways++
else if (['startEvent', 'endEvent', 'intermediateThrowEvent', 'intermediateCatchEvent'].includes(node.type)) events++
else unknown++
}
return { tasks, gateways, events, unknown }
}
// ─── simple-linear.bpmn ──────────────────────────────────────────────────────
describe('parser / simple-linear.bpmn', () => {
const xml = loadSample('simple-linear.bpmn')
it('parsea sin lanzar error', () => {
expect(() => parseBpmnXml(xml)).not.toThrow()
})
it('grafo: 7 nodos y 6 flujos de secuencia', () => {
const g = parseBpmnXml(xml)
expect(g.nodes.size).toBe(7)
expect(g.flows.size).toBe(6)
})
it('grafo: 5 tasks | 0 gateways | 2 eventos', () => {
const g = parseBpmnXml(xml)
const { tasks, gateways, events, unknown } = countByCategory(g)
expect(tasks).toBe(5)
expect(gateways).toBe(0)
expect(events).toBe(2)
expect(unknown).toBe(0)
})
it('1 startEvent detectado como punto de entrada', () => {
const g = parseBpmnXml(xml)
expect(g.startEventIds).toHaveLength(1)
expect(g.startEventIds[0]).toBe('start')
})
it('extractActivityElements: 5 actividades con nombres correctos', () => {
const g = parseBpmnXml(xml)
const acts = extractActivityElements(g)
expect(acts).toHaveLength(5)
const names = acts.map((a) => a.name)
expect(names).toContain('Recibir solicitud')
expect(names).toContain('Verificar documentación')
expect(names).toContain('Procesar pedido')
expect(names).toContain('Notificar al cliente')
expect(names).toContain('Archivar expediente')
acts.forEach((a) => expect(a.type).toBe('task'))
})
it('extractGatewayElements: 0 gateways divergentes (proceso lineal)', () => {
const g = parseBpmnXml(xml)
const gws = extractGatewayElements(g)
expect(gws).toHaveLength(0)
})
})
// ─── medium-with-gateways.bpmn ───────────────────────────────────────────────
describe('parser / medium-with-gateways.bpmn', () => {
const xml = loadSample('medium-with-gateways.bpmn')
it('parsea sin lanzar error', () => {
expect(() => parseBpmnXml(xml)).not.toThrow()
})
it('grafo: 17 nodos y 19 flujos de secuencia', () => {
const g = parseBpmnXml(xml)
expect(g.nodes.size).toBe(17)
expect(g.flows.size).toBe(19)
})
it('grafo: 11 tasks | 4 gateways | 2 eventos', () => {
const g = parseBpmnXml(xml)
const { tasks, gateways, events, unknown } = countByCategory(g)
expect(tasks).toBe(11)
expect(gateways).toBe(4)
expect(events).toBe(2)
expect(unknown).toBe(0)
})
it('extractActivityElements: exactamente 11 actividades', () => {
const g = parseBpmnXml(xml)
const acts = extractActivityElements(g)
expect(acts).toHaveLength(11)
const ids = acts.map((a) => a.bpmnElementId)
expect(ids).toContain('task_recv')
expect(ids).toContain('task_valid')
expect(ids).toContain('task_pedir_docs')
expect(ids).toContain('task_score')
expect(ids).toContain('task_bureau')
expect(ids).toContain('task_empleador')
expect(ids).toContain('task_analisis')
expect(ids).toContain('task_aprobar')
expect(ids).toContain('task_rechazar')
expect(ids).toContain('task_desembolso')
expect(ids).toContain('task_archivo')
})
it('extractGatewayElements: 3 gateways divergentes con salidas correctas', () => {
const g = parseBpmnXml(xml)
const gws = extractGatewayElements(g)
expect(gws).toHaveLength(3)
const byId = Object.fromEntries(gws.map((gw) => [gw.bpmnElementId, gw]))
// gw_datos: XOR con 2 salidas
expect(byId['gw_datos']).toBeDefined()
expect(byId['gw_datos'].outgoing).toHaveLength(2)
expect(byId['gw_datos'].gatewayType).toBe('exclusiveGateway')
// gw_parallel_split: AND con 2 salidas
expect(byId['gw_parallel_split']).toBeDefined()
expect(byId['gw_parallel_split'].outgoing).toHaveLength(2)
expect(byId['gw_parallel_split'].gatewayType).toBe('parallelGateway')
// gw_decision: XOR con 2 salidas
expect(byId['gw_decision']).toBeDefined()
expect(byId['gw_decision'].outgoing).toHaveLength(2)
expect(byId['gw_decision'].gatewayType).toBe('exclusiveGateway')
// gw_parallel_join NO debe estar (convergente: 1 salida)
expect(byId['gw_parallel_join']).toBeUndefined()
})
})
// ─── complex-with-loop.bpmn ───────────────────────────────────────────────────
describe('parser / complex-with-loop.bpmn', () => {
const xml = loadSample('complex-with-loop.bpmn')
it('parsea sin lanzar error', () => {
expect(() => parseBpmnXml(xml)).not.toThrow()
})
it('grafo: 13 nodos y 14 flujos de secuencia', () => {
const g = parseBpmnXml(xml)
expect(g.nodes.size).toBe(13)
expect(g.flows.size).toBe(14)
})
it('grafo: 9 tasks | 2 gateways | 2 eventos', () => {
const g = parseBpmnXml(xml)
const { tasks, gateways, events, unknown } = countByCategory(g)
expect(tasks).toBe(9)
expect(gateways).toBe(2)
expect(events).toBe(2)
expect(unknown).toBe(0)
})
it('extractActivityElements: exactamente 9 actividades', () => {
const g = parseBpmnXml(xml)
const acts = extractActivityElements(g)
expect(acts).toHaveLength(9)
const ids = acts.map((a) => a.bpmnElementId)
expect(ids).toContain('task_preparar')
expect(ids).toContain('task_inspeccion')
expect(ids).toContain('task_documentar')
expect(ids).toContain('task_correcciones')
expect(ids).toContain('task_rechazo')
expect(ids).toContain('task_informe_rechazo')
expect(ids).toContain('task_entrega')
expect(ids).toContain('task_certificado')
expect(ids).toContain('task_registro')
})
it('extractGatewayElements: 1 gateway divergente (gw_resultado con 3 salidas)', () => {
const g = parseBpmnXml(xml)
const gws = extractGatewayElements(g)
expect(gws).toHaveLength(1)
expect(gws[0].bpmnElementId).toBe('gw_resultado')
expect(gws[0].outgoing).toHaveLength(3)
expect(gws[0].gatewayType).toBe('exclusiveGateway')
// gw_merge es convergente (1 salida) — no debe aparecer
})
it('el flujo de loop existe en el grafo (correc_inspeccion apunta a task_inspeccion)', () => {
const g = parseBpmnXml(xml)
const loopFlow = g.flows.get('flow_correc_inspeccion')
expect(loopFlow).toBeDefined()
expect(loopFlow!.sourceRef).toBe('task_correcciones')
expect(loopFlow!.targetRef).toBe('task_inspeccion')
})
})
// ─── Casos de error del parser ────────────────────────────────────────────────
describe('parser / manejo de errores', () => {
it('lanza BpmnParseError si el XML no tiene elemento <process>', () => {
expect(() => parseBpmnXml('<definitions><notAProcess/></definitions>')).toThrow(BpmnParseError)
})
it('lanza BpmnParseError si el XML está malformado', () => {
expect(() => parseBpmnXml('<definitions><process id="p"><unclosed')).toThrow(BpmnParseError)
})
it('lanza BpmnParseError si el proceso no tiene startEvent', () => {
const xml = `<?xml version="1.0"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL">
<process id="p"><task id="t1" name="Solo tarea"/></process>
</definitions>`
expect(() => parseBpmnXml(xml)).toThrow(BpmnParseError)
})
})

View File

@@ -0,0 +1,157 @@
/**
* Tests permanentes de validación de gateways.
* Protegen las reglas de negocio: XOR debe sumar 1, AND no tiene probs editables.
*/
import { describe, it, expect } from 'vitest'
import { isXorSumValid } from '@/features/workspace/GatewayPanel'
import { runSimulation } from '@/domain/simulation'
import type { GatewayConfig, SimulationInput, Activity } from '@/domain/types'
import { v4 as uuidv4 } from 'uuid'
import { readFileSync } from 'fs'
import { resolve } from 'path'
function makeBranches(probs: number[]): GatewayConfig['branches'] {
return probs.map((p, i) => ({ flowId: `f${i}`, targetElementId: `t${i}`, probability: p }))
}
function makeXorGateway(probs: number[]): GatewayConfig {
return {
id: uuidv4(), processId: 'p', bpmnElementId: 'gw1',
gatewayType: 'exclusive',
branches: makeBranches(probs),
}
}
function makeParallelGateway(n: number): GatewayConfig {
return {
id: uuidv4(), processId: 'p', bpmnElementId: 'gw1',
gatewayType: 'parallel',
branches: makeBranches(Array(n).fill(1.0)),
}
}
// ─── isXorSumValid ────────────────────────────────────────────────────────────
describe('isXorSumValid', () => {
it('válido: suma exactamente 1.0', () => {
expect(isXorSumValid(makeXorGateway([0.6, 0.4]))).toBe(true)
})
it('válido: suma = 1.0 con 3 ramas (0.5 + 0.3 + 0.2)', () => {
expect(isXorSumValid(makeXorGateway([0.5, 0.3, 0.2]))).toBe(true)
})
it('válido: tolerancia de 0.5% — 0.999 se acepta como 1.0', () => {
expect(isXorSumValid(makeXorGateway([0.499, 0.501]))).toBe(true)
})
it('inválido: suma = 0.5 (faltan 50%)', () => {
expect(isXorSumValid(makeXorGateway([0.3, 0.2]))).toBe(false)
})
it('inválido: suma = 1.5 (excede 100%)', () => {
expect(isXorSumValid(makeXorGateway([0.8, 0.7]))).toBe(false)
})
it('inválido: suma = 0 (gateways sin configurar)', () => {
expect(isXorSumValid(makeXorGateway([0, 0]))).toBe(false)
})
it('gateway paralelo siempre válido (no aplica regla de suma)', () => {
expect(isXorSumValid(makeParallelGateway(2))).toBe(true)
expect(isXorSumValid(makeParallelGateway(3))).toBe(true)
})
it('gateway inclusivo tratado como exclusivo — aplica regla de suma', () => {
const gw: GatewayConfig = { ...makeXorGateway([0.6, 0.4]), gatewayType: 'inclusive' }
// inclusive usa isXorSumValid solo para el display; el motor lo trata igual
// Aquí verificamos que la función no lanza para gateways inclusive
expect(() => isXorSumValid(gw)).not.toThrow()
})
})
// ─── Motor: gateways con distribución 70/30 sobre medium BPMN ────────────────
describe('motor / propagación con GatewayConfig correcta', () => {
const xml = readFileSync(resolve(__dirname, '../../public/sample-processes/medium-with-gateways.bpmn'), 'utf-8')
function buildSimInput(datosProbs: [number, number], decisionProbs: [number, number]): SimulationInput {
const processId = 'gv-test'
const actIds = ['task_recv', 'task_valid', 'task_pedir_docs', 'task_score',
'task_bureau', 'task_empleador', 'task_analisis',
'task_aprobar', 'task_rechazar', 'task_desembolso', 'task_archivo']
const activities = new Map<string, Activity>(
actIds.map((id) => [id, {
id: uuidv4(), processId, bpmnElementId: id, name: id,
type: 'task', directCostFixed: 100, executionTimeMinutes: 30, assignedResources: [],
}])
)
const gateways = new Map<string, GatewayConfig>([
['gw_datos', {
id: uuidv4(), processId, bpmnElementId: 'gw_datos', gatewayType: 'exclusive',
branches: [
{ flowId: 'flow_gw1_completo', targetElementId: 'task_score', probability: datosProbs[0] },
{ flowId: 'flow_gw1_incompleto', targetElementId: 'task_pedir_docs', probability: datosProbs[1] },
],
}],
['gw_parallel_split', {
id: uuidv4(), processId, bpmnElementId: 'gw_parallel_split', gatewayType: 'parallel',
branches: [
{ flowId: 'flow_para_bureau', targetElementId: 'task_bureau', probability: 1.0 },
{ flowId: 'flow_para_empleador', targetElementId: 'task_empleador', probability: 1.0 },
],
}],
['gw_decision', {
id: uuidv4(), processId, bpmnElementId: 'gw_decision', gatewayType: 'exclusive',
branches: [
{ flowId: 'flow_dec_aprobado', targetElementId: 'task_aprobar', probability: decisionProbs[0] },
{ flowId: 'flow_dec_rechazado', targetElementId: 'task_rechazar', probability: decisionProbs[1] },
],
}],
])
return { processXml: xml, activities, gateways, resources: new Map(), globalSettings: { currency: 'USD', overheadPercentage: 0 } }
}
it('rama "completo" 70% → task_score tiene prob ≈ 0.7', () => {
const result = runSimulation(buildSimInput([0.7, 0.3], [0.8, 0.2]))
const score = result.perActivity.find((a) => a.bpmnElementId === 'task_score')
expect(score?.executionProbability).toBeCloseTo(0.7, 2)
})
it('rama "incompleto" 30% → task_pedir_docs tiene prob ≈ 0.3', () => {
const result = runSimulation(buildSimInput([0.7, 0.3], [0.8, 0.2]))
const pedir = result.perActivity.find((a) => a.bpmnElementId === 'task_pedir_docs')
expect(pedir?.executionProbability).toBeCloseTo(0.3, 2)
})
it('task_aprobar tiene prob = 0.8 (task_analisis converge ambos caminos a 1.0, luego 80% decision)', () => {
// task_analisis recibe:
// - camino completo (70%): AND join correcto → 0.7
// - camino incompleto (30%): task_pedir_docs → 0.3
// - suma XOR: 0.7 + 0.3 = 1.0
// gw_decision → task_aprobar: 1.0 × 0.8 = 0.8
const result = runSimulation(buildSimInput([0.7, 0.3], [0.8, 0.2]))
const analisis = result.perActivity.find((a) => a.bpmnElementId === 'task_analisis')
const aprobar = result.perActivity.find((a) => a.bpmnElementId === 'task_aprobar')
const rechazar = result.perActivity.find((a) => a.bpmnElementId === 'task_rechazar')
expect(analisis?.executionProbability).toBeCloseTo(1.0, 2)
expect(aprobar?.executionProbability).toBeCloseTo(0.8, 2)
expect(rechazar?.executionProbability).toBeCloseTo(0.2, 2)
})
it('gateway AND: bureau y empleador tienen la misma prob que score', () => {
const result = runSimulation(buildSimInput([1.0, 0.0], [0.5, 0.5]))
const bureau = result.perActivity.find((a) => a.bpmnElementId === 'task_bureau')
const empleador = result.perActivity.find((a) => a.bpmnElementId === 'task_empleador')
// Ambos deben tener la misma probabilidad (AND paralelo)
expect(bureau?.executionProbability).toBeCloseTo(empleador?.executionProbability ?? -1, 2)
})
it('con probs que no suman 1 el motor no explota, solo produce resultados parciales', () => {
// Esto valida que el motor es robusto aunque la UI deba prevenirlo
expect(() => runSimulation(buildSimInput([0.3, 0.3], [0.5, 0.5]))).not.toThrow()
})
})

View File

@@ -0,0 +1,331 @@
/**
* Tests permanentes de persistencia en IndexedDB vía Dexie.
* Usan fake-indexeddb (parcheado en tests/setup.ts) para correr en Node.
*
* Cada test limpia las tablas en beforeEach para garantizar aislamiento.
* Verifican que los repositorios escriben y recuperan datos correctamente.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { db } from '@/persistence/db'
import {
processRepo,
activityRepo,
gatewayRepo,
resourceRepo,
simulationRepo,
} from '@/persistence/repositories'
import type { Process, Activity, GatewayConfig, Resource, Simulation, SimulationResult } from '@/domain/types'
// ─── Fixtures ─────────────────────────────────────────────────────────────────
function makeProcess(id = 'proc-1'): Process {
return {
id,
name: 'Proceso de prueba',
clientName: 'Cliente Test',
bpmnXml: '<definitions/>',
currency: 'USD',
overheadPercentage: 0.2,
createdAt: 1000,
updatedAt: 2000,
}
}
function makeActivity(id: string, processId: string, bpmnElementId: string): Activity {
return {
id,
processId,
bpmnElementId,
name: `Tarea ${bpmnElementId}`,
type: 'task',
directCostFixed: 100,
executionTimeMinutes: 30,
assignedResources: [{ resourceId: 'res-1', utilizationPercent: 0.5 }],
}
}
function makeGateway(id: string, processId: string, bpmnElementId: string): GatewayConfig {
return {
id,
processId,
bpmnElementId,
gatewayType: 'exclusive',
branches: [
{ flowId: 'f1', targetElementId: 'taskA', probability: 0.7 },
{ flowId: 'f2', targetElementId: 'taskB', probability: 0.3 },
],
}
}
function makeResource(id: string, processId: string): Resource {
return {
id,
processId,
name: 'Analista',
type: 'role',
costPerHour: 60,
}
}
function makeSimulation(id: string, processId: string): Simulation {
const result: SimulationResult = {
totalCost: 1200,
totalDirectCost: 1000,
totalIndirectCost: 200,
totalTimeMinutes: 90,
perActivity: [],
perResource: [],
warnings: [],
}
return { id, processId, executedAt: 3000, result }
}
// ─── Setup / Teardown ─────────────────────────────────────────────────────────
beforeEach(async () => {
await db.open()
await db.transaction(
'rw',
[db.processes, db.activities, db.gateways, db.resources, db.simulations],
async () => {
await Promise.all([
db.processes.clear(),
db.activities.clear(),
db.gateways.clear(),
db.resources.clear(),
db.simulations.clear(),
])
}
)
})
afterEach(async () => {
await db.close()
})
// ─── Process ─────────────────────────────────────────────────────────────────
describe('IndexedDB / processRepo', () => {
it('guarda un proceso y lo recupera por id', async () => {
const proc = makeProcess()
await processRepo.save(proc)
const retrieved = await processRepo.getById('proc-1')
expect(retrieved).toBeDefined()
expect(retrieved!.name).toBe('Proceso de prueba')
expect(retrieved!.currency).toBe('USD')
expect(retrieved!.overheadPercentage).toBe(0.2)
})
it('getById devuelve undefined para id inexistente', async () => {
const result = await processRepo.getById('no-existe')
expect(result).toBeUndefined()
})
it('getAll devuelve todos los procesos guardados', async () => {
await processRepo.save(makeProcess('p1'))
await processRepo.save(makeProcess('p2'))
await processRepo.save(makeProcess('p3'))
const all = await processRepo.getAll()
expect(all).toHaveLength(3)
})
it('save sobrescribe (put) un proceso existente', async () => {
const proc = makeProcess()
await processRepo.save(proc)
await processRepo.save({ ...proc, name: 'Nombre actualizado', updatedAt: 9999 })
const updated = await processRepo.getById('proc-1')
expect(updated!.name).toBe('Nombre actualizado')
expect(updated!.updatedAt).toBe(9999)
})
it('delete elimina el proceso y sus entidades relacionadas en cascada', async () => {
const proc = makeProcess('proc-del')
await processRepo.save(proc)
await activityRepo.saveMany([makeActivity('a1', 'proc-del', 'task1')])
await gatewayRepo.saveMany([makeGateway('g1', 'proc-del', 'gw1')])
await resourceRepo.save(makeResource('r1', 'proc-del'))
await processRepo.delete('proc-del')
expect(await processRepo.getById('proc-del')).toBeUndefined()
expect(await activityRepo.getByProcess('proc-del')).toHaveLength(0)
expect(await gatewayRepo.getByProcess('proc-del')).toHaveLength(0)
expect(await resourceRepo.getByProcess('proc-del')).toHaveLength(0)
})
})
// ─── Activity ─────────────────────────────────────────────────────────────────
describe('IndexedDB / activityRepo', () => {
const processId = 'proc-act'
it('saveMany y getByProcess devuelven las mismas actividades', async () => {
const acts = [
makeActivity('a1', processId, 'task1'),
makeActivity('a2', processId, 'task2'),
makeActivity('a3', processId, 'task3'),
]
await activityRepo.saveMany(acts)
const retrieved = await activityRepo.getByProcess(processId)
expect(retrieved).toHaveLength(3)
const ids = retrieved.map((a) => a.id).sort()
expect(ids).toEqual(['a1', 'a2', 'a3'])
})
it('persiste assignedResources (array anidado) correctamente', async () => {
const act = makeActivity('a1', processId, 'task1')
await activityRepo.save(act)
const [retrieved] = await activityRepo.getByProcess(processId)
expect(retrieved.assignedResources).toHaveLength(1)
expect(retrieved.assignedResources[0].resourceId).toBe('res-1')
expect(retrieved.assignedResources[0].utilizationPercent).toBe(0.5)
})
it('no devuelve actividades de otro proceso', async () => {
await activityRepo.save(makeActivity('a-other', 'otro-proc', 'task1'))
const result = await activityRepo.getByProcess(processId)
expect(result).toHaveLength(0)
})
})
// ─── GatewayConfig ────────────────────────────────────────────────────────────
describe('IndexedDB / gatewayRepo', () => {
const processId = 'proc-gw'
it('saveMany y getByProcess devuelven la config con branches correctas', async () => {
const gw = makeGateway('g1', processId, 'gw_decision')
await gatewayRepo.saveMany([gw])
const [retrieved] = await gatewayRepo.getByProcess(processId)
expect(retrieved.bpmnElementId).toBe('gw_decision')
expect(retrieved.gatewayType).toBe('exclusive')
expect(retrieved.branches).toHaveLength(2)
expect(retrieved.branches[0].probability).toBe(0.7)
expect(retrieved.branches[1].probability).toBe(0.3)
})
it('save individual actualiza la config existente', async () => {
const gw = makeGateway('g1', processId, 'gw1')
await gatewayRepo.save(gw)
const updated: GatewayConfig = {
...gw,
branches: [
{ flowId: 'f1', targetElementId: 'taskA', probability: 0.6 },
{ flowId: 'f2', targetElementId: 'taskB', probability: 0.4 },
],
}
await gatewayRepo.save(updated)
const [retrieved] = await gatewayRepo.getByProcess(processId)
expect(retrieved.branches[0].probability).toBe(0.6)
})
})
// ─── Resource ─────────────────────────────────────────────────────────────────
describe('IndexedDB / resourceRepo', () => {
const processId = 'proc-res'
it('save y getByProcess recupera el recurso correctamente', async () => {
await resourceRepo.save(makeResource('r1', processId))
const [retrieved] = await resourceRepo.getByProcess(processId)
expect(retrieved.name).toBe('Analista')
expect(retrieved.costPerHour).toBe(60)
expect(retrieved.type).toBe('role')
})
it('delete elimina un recurso por id', async () => {
await resourceRepo.save(makeResource('r1', processId))
await resourceRepo.save(makeResource('r2', processId))
await resourceRepo.delete('r1')
const remaining = await resourceRepo.getByProcess(processId)
expect(remaining).toHaveLength(1)
expect(remaining[0].id).toBe('r2')
})
it('múltiples recursos para el mismo proceso', async () => {
const types = ['role', 'system', 'equipment'] as const
for (const [i, type] of types.entries()) {
await resourceRepo.save({ ...makeResource(`r${i}`, processId), type })
}
const all = await resourceRepo.getByProcess(processId)
expect(all).toHaveLength(3)
const typeSet = new Set(all.map((r) => r.type))
expect(typeSet.has('role')).toBe(true)
expect(typeSet.has('system')).toBe(true)
expect(typeSet.has('equipment')).toBe(true)
})
})
// ─── Simulation ───────────────────────────────────────────────────────────────
describe('IndexedDB / simulationRepo', () => {
const processId = 'proc-sim'
it('save y getLatestByProcess recuperan el snapshot de resultado', async () => {
const sim = makeSimulation('sim-1', processId)
await simulationRepo.save(sim)
const retrieved = await simulationRepo.getLatestByProcess(processId)
expect(retrieved).toBeDefined()
expect(retrieved!.result.totalCost).toBe(1200)
expect(retrieved!.result.totalDirectCost).toBe(1000)
expect(retrieved!.result.totalIndirectCost).toBe(200)
})
it('getLatestByProcess devuelve undefined si no hay simulaciones', async () => {
const result = await simulationRepo.getLatestByProcess('proc-sin-sim')
expect(result).toBeUndefined()
})
it('múltiples simulaciones: getByProcess las recupera todas', async () => {
await simulationRepo.save({ ...makeSimulation('s1', processId), executedAt: 1000 })
await simulationRepo.save({ ...makeSimulation('s2', processId), executedAt: 2000 })
await simulationRepo.save({ ...makeSimulation('s3', processId), executedAt: 3000 })
const all = await simulationRepo.getByProcess(processId)
expect(all).toHaveLength(3)
})
it('persiste warnings en el resultado (campo libre)', async () => {
const sim: Simulation = {
...makeSimulation('s-warn', processId),
result: {
...makeSimulation('s-warn', processId).result,
warnings: ['Loop detectado en taskX'],
},
}
await simulationRepo.save(sim)
const retrieved = await simulationRepo.getLatestByProcess(processId)
expect(retrieved!.result.warnings).toHaveLength(1)
expect(retrieved!.result.warnings[0]).toContain('Loop detectado')
})
})
// ─── Round-trip completo (simula el flujo de ImportPage) ─────────────────────
describe('IndexedDB / round-trip proceso completo', () => {
it('guarda proceso + actividades + gateways + recursos, luego los recupera íntegros', async () => {
const proc = makeProcess('rt-proc')
const acts = [
makeActivity('rt-a1', 'rt-proc', 'taskA'),
makeActivity('rt-a2', 'rt-proc', 'taskB'),
]
const gws = [makeGateway('rt-g1', 'rt-proc', 'gw1')]
const resources = [makeResource('rt-r1', 'rt-proc')]
await Promise.all([
processRepo.save(proc),
activityRepo.saveMany(acts),
gatewayRepo.saveMany(gws),
resourceRepo.save(resources[0]),
])
// Simular recarga de página
const [retrievedProc, retrievedActs, retrievedGws, retrievedRes] = await Promise.all([
processRepo.getById('rt-proc'),
activityRepo.getByProcess('rt-proc'),
gatewayRepo.getByProcess('rt-proc'),
resourceRepo.getByProcess('rt-proc'),
])
expect(retrievedProc!.id).toBe('rt-proc')
expect(retrievedActs).toHaveLength(2)
expect(retrievedGws).toHaveLength(1)
expect(retrievedGws[0].branches).toHaveLength(2)
expect(retrievedRes).toHaveLength(1)
expect(retrievedRes[0].costPerHour).toBe(60)
})
})

View File

@@ -0,0 +1,278 @@
/**
* Tests permanentes del motor de simulación corriendo sobre los 3 sample BPMNs.
* Protegen contra regresiones en runSimulation, la propagación de probabilidades
* y la detección de loops.
*
* Costos dummy predecibles para que las aserciones sean deterministas.
*/
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 { bpmnTypeToGatewayType } from '@/domain/types'
import type { Activity, GatewayConfig, Resource, SimulationInput } from '@/domain/types'
function loadSample(name: string): string {
return readFileSync(resolve(__dirname, '../../public/sample-processes', name), 'utf-8')
}
// Construye un SimulationInput con costos fijos iguales para todas las actividades
function buildInput(
xml: string,
opts: {
directCostFixed?: number
executionTimeMinutes?: number
overheadPercentage?: number
gatewayProbsOverride?: Record<string, number[]>
} = {}
): SimulationInput {
const {
directCostFixed = 100,
executionTimeMinutes = 30,
overheadPercentage = 0.2,
gatewayProbsOverride = {},
} = opts
const processId = 'sim-test'
const graph = parseBpmnXml(xml)
const actEls = extractActivityElements(graph)
const gwEls = extractGatewayElements(graph)
const activities = new Map<string, Activity>(
actEls.map((el) => [
el.bpmnElementId,
{
id: uuidv4(),
processId,
bpmnElementId: el.bpmnElementId,
name: el.name,
type: el.type,
directCostFixed,
executionTimeMinutes,
assignedResources: [],
},
])
)
const gateways = new Map<string, GatewayConfig>(
gwEls.map((el) => {
const gwType = bpmnTypeToGatewayType(el.gatewayType as any)
const n = el.outgoing.length
const probsOverride = gatewayProbsOverride[el.bpmnElementId]
return [
el.bpmnElementId,
{
id: uuidv4(),
processId,
bpmnElementId: el.bpmnElementId,
gatewayType: gwType,
branches: el.outgoing.map((flowId, i) => ({
flowId,
targetElementId: graph.flows.get(flowId)?.targetRef ?? '',
probability:
probsOverride?.[i] ??
(gwType === 'parallel' ? 1.0 : parseFloat((1 / n).toFixed(6))),
})),
},
]
})
)
return {
processXml: xml,
activities,
gateways,
resources: new Map<string, Resource>(),
globalSettings: { currency: 'USD', overheadPercentage },
}
}
// ─── simple-linear.bpmn ──────────────────────────────────────────────────────
describe('simulación / simple-linear.bpmn', () => {
const xml = loadSample('simple-linear.bpmn')
it('corre sin lanzar error con costos dummy', () => {
expect(() => runSimulation(buildInput(xml))).not.toThrow()
})
it('no genera warnings (proceso sin loops ni gateways)', () => {
const result = runSimulation(buildInput(xml))
expect(result.warnings).toHaveLength(0)
})
it('costo total = 5 × 100 × 1.2 = 600 (5 tareas, prob=1, overhead=20%)', () => {
const result = runSimulation(buildInput(xml, { directCostFixed: 100, overheadPercentage: 0.2 }))
expect(result.totalDirectCost).toBe(500)
expect(result.totalIndirectCost).toBeCloseTo(100)
expect(result.totalCost).toBeCloseTo(600)
})
it('perActivity tiene 5 entradas, todas con executionProbability = 1', () => {
const result = runSimulation(buildInput(xml))
expect(result.perActivity).toHaveLength(5)
result.perActivity.forEach((a) => {
expect(a.executionProbability).toBeCloseTo(1.0)
})
})
it('perActivity ordenado descendente por expectedTotalCost', () => {
const result = runSimulation(buildInput(xml, { directCostFixed: 100 }))
for (let i = 0; i < result.perActivity.length - 1; i++) {
expect(result.perActivity[i].expectedTotalCost).toBeGreaterThanOrEqual(
result.perActivity[i + 1].expectedTotalCost
)
}
})
it('percentOfTotal suma exactamente 100%', () => {
const result = runSimulation(buildInput(xml))
const sum = result.perActivity.reduce((acc, a) => acc + a.percentOfTotal, 0)
expect(sum).toBeCloseTo(100, 1)
})
it('tiempo total = 5 × 30 min = 150 min', () => {
const result = runSimulation(buildInput(xml, { executionTimeMinutes: 30 }))
expect(result.totalTimeMinutes).toBeCloseTo(150)
})
})
// ─── medium-with-gateways.bpmn ───────────────────────────────────────────────
describe('simulación / medium-with-gateways.bpmn', () => {
const xml = loadSample('medium-with-gateways.bpmn')
it('corre sin lanzar error con costos dummy', () => {
expect(() => runSimulation(buildInput(xml))).not.toThrow()
})
it('no genera warnings (sin loops)', () => {
const result = runSimulation(buildInput(xml))
expect(result.warnings).toHaveLength(0)
})
it('perActivity tiene exactamente 11 entradas', () => {
const result = runSimulation(buildInput(xml))
expect(result.perActivity).toHaveLength(11)
})
it('actividades antes de cualquier gateway tienen prob = 1.0', () => {
const result = runSimulation(buildInput(xml))
const recv = result.perActivity.find((a) => a.bpmnElementId === 'task_recv')
const valid = result.perActivity.find((a) => a.bpmnElementId === 'task_valid')
expect(recv?.executionProbability).toBeCloseTo(1.0)
expect(valid?.executionProbability).toBeCloseTo(1.0)
})
it('gateway XOR con probs 0.7/0.3 propaga correctamente', () => {
// gw_datos: completo(idx 0)=0.7, incompleto(idx 1)=0.3
const result = runSimulation(
buildInput(xml, {
gatewayProbsOverride: { gw_datos: [0.7, 0.3] },
})
)
const score = result.perActivity.find((a) => a.bpmnElementId === 'task_score')
const pedirDocs = result.perActivity.find((a) => a.bpmnElementId === 'task_pedir_docs')
expect(score?.executionProbability).toBeCloseTo(0.7, 1)
expect(pedirDocs?.executionProbability).toBeCloseTo(0.3, 1)
})
it('gateway paralelo AND: ambas ramas tienen prob igual a la entrada', () => {
// gw_datos completo=1.0 para simplificar, gw_parallel_split AND ambas =1
const result = runSimulation(
buildInput(xml, {
gatewayProbsOverride: {
gw_datos: [1.0, 0.0], // siempre el camino completo
gw_decision: [0.5, 0.5],
},
})
)
const bureau = result.perActivity.find((a) => a.bpmnElementId === 'task_bureau')
const empleador = result.perActivity.find((a) => a.bpmnElementId === 'task_empleador')
// Ambas ramas del AND deben tener prob similar (≈ prob del score)
expect(bureau?.executionProbability).toBeGreaterThan(0)
expect(empleador?.executionProbability).toBeGreaterThan(0)
expect(Math.abs((bureau?.executionProbability ?? 0) - (empleador?.executionProbability ?? 0))).toBeLessThan(0.01)
})
it('perActivity ordenado descendente por costo', () => {
const result = runSimulation(buildInput(xml))
for (let i = 0; i < result.perActivity.length - 1; i++) {
expect(result.perActivity[i].expectedTotalCost).toBeGreaterThanOrEqual(
result.perActivity[i + 1].expectedTotalCost
)
}
})
})
// ─── complex-with-loop.bpmn ───────────────────────────────────────────────────
describe('simulación / complex-with-loop.bpmn', () => {
const xml = loadSample('complex-with-loop.bpmn')
it('corre sin lanzar error (a pesar del loop)', () => {
expect(() => runSimulation(buildInput(xml))).not.toThrow()
})
it('🚨 genera al menos un warning de loop detectado', () => {
const result = runSimulation(buildInput(xml))
expect(result.warnings.length).toBeGreaterThan(0)
const hasLoopWarning = result.warnings.some(
(w) => w.toLowerCase().includes('loop') || w.toLowerCase().includes('ciclo')
)
expect(hasLoopWarning).toBe(true)
})
it('el warning menciona el nodo "Inspección visual y funcional"', () => {
const result = runSimulation(buildInput(xml))
const loopWarning = result.warnings.find(
(w) => w.toLowerCase().includes('loop') || w.toLowerCase().includes('ciclo')
)
expect(loopWarning).toContain('Inspección visual y funcional')
})
it('resultado es válido a pesar del loop: costo > 0 y 9 actividades', () => {
const result = runSimulation(buildInput(xml, { directCostFixed: 100 }))
expect(result.totalCost).toBeGreaterThan(0)
expect(result.perActivity).toHaveLength(9)
})
it('con probabilidades reales (60% aprobado, 30% correcciones, 10% rechazo)', () => {
const result = runSimulation(
buildInput(xml, {
directCostFixed: 200,
overheadPercentage: 0.15,
gatewayProbsOverride: {
// gw_resultado: 3 salidas — aprobado 0.6, obs_menores 0.3, rechazo 0.1
gw_resultado: [0.6, 0.3, 0.1],
},
})
)
expect(result.totalCost).toBeGreaterThan(0)
expect(result.warnings.length).toBeGreaterThan(0)
// task_inspeccion tiene prob alta (es el nodo más costoso del proceso con loop)
const inspeccion = result.perActivity.find((a) => a.bpmnElementId === 'task_inspeccion')
expect(inspeccion).toBeDefined()
expect(inspeccion!.executionProbability).toBeGreaterThan(0)
// task_correcciones tiene prob = prob del camino de correcciones ≈ 0.3
const correcciones = result.perActivity.find((a) => a.bpmnElementId === 'task_correcciones')
expect(correcciones).toBeDefined()
expect(correcciones!.executionProbability).toBeCloseTo(0.3, 1)
// task_rechazo tiene prob ≈ 0.1
const rechazo = result.perActivity.find((a) => a.bpmnElementId === 'task_rechazo')
expect(rechazo).toBeDefined()
expect(rechazo!.executionProbability).toBeCloseTo(0.1, 1)
})
it('percentOfTotal suma ≈ 100%', () => {
const result = runSimulation(buildInput(xml))
if (result.perActivity.length > 0) {
const sum = result.perActivity.reduce((acc, a) => acc + a.percentOfTotal, 0)
expect(sum).toBeCloseTo(100, 0)
}
})
})

213
tests/lib/colors.test.ts Normal file
View File

@@ -0,0 +1,213 @@
import { describe, it, expect } from 'vitest'
import {
heatmapColorHex,
computeActivityT,
hasSignificantCostVariance,
activityColor,
NEUTRAL_HEATMAP_COLOR,
type ActivityCostData,
} from '@/lib/colors'
// ─── Fixtures ─────────────────────────────────────────────────────────────────
function makeActs(costs: number[]): ActivityCostData[] {
const total = costs.reduce((s, c) => s + c, 0) || 1
return costs.map((c) => ({
expectedTotalCost: Math.max(0, c),
percentOfTotal: (c / total) * 100,
}))
}
// ─── heatmapColorHex ─────────────────────────────────────────────────────────
describe('heatmapColorHex', () => {
it('t=0 → verde (#10b981)', () => {
expect(heatmapColorHex(0)).toBe('#10b981')
})
it('t=1 → rojo (#ef4444)', () => {
expect(heatmapColorHex(1)).toBe('#ef4444')
})
it('t=0.5 → amarillo (#f59e0b)', () => {
expect(heatmapColorHex(0.5)).toBe('#f59e0b')
})
it('t<0 → clampea a t=0 (verde)', () => {
expect(heatmapColorHex(-1)).toBe(heatmapColorHex(0))
})
it('t>1 → clampea a t=1 (rojo)', () => {
expect(heatmapColorHex(2)).toBe(heatmapColorHex(1))
})
it('devuelve string hex válido en formato #rrggbb', () => {
for (const t of [0, 0.25, 0.5, 0.75, 1]) {
const hex = heatmapColorHex(t)
expect(hex).toMatch(/^#[0-9a-f]{6}$/)
}
})
it('colores intermedios son distintos entre sí', () => {
const colors = [0, 0.25, 0.5, 0.75, 1].map(heatmapColorHex)
const unique = new Set(colors)
expect(unique.size).toBe(5)
})
})
// ─── hasSignificantCostVariance ──────────────────────────────────────────────
describe('hasSignificantCostVariance', () => {
it('varianza cero: todos iguales → false', () => {
expect(hasSignificantCostVariance(makeActs([100, 100, 100, 100, 100]))).toBe(false)
})
it('una sola actividad → false (no hay con qué comparar)', () => {
expect(hasSignificantCostVariance(makeActs([500]))).toBe(false)
})
it('lista vacía → false', () => {
expect(hasSignificantCostVariance([])).toBe(false)
})
it('varianza < 5%: costos casi iguales → false', () => {
// rango = (103-100)/103 ≈ 2.9% < 5%
expect(hasSignificantCostVariance(makeActs([100, 101, 102, 103]))).toBe(false)
})
it('varianza > 5%: costos claramente distintos → true', () => {
// rango = (200-100)/200 = 50% > 5%
expect(hasSignificantCostVariance(makeActs([100, 200]))).toBe(true)
})
it('varianza ≈ THRESHOLD (5%): está justo en el límite', () => {
// rango = (105-100)/105 ≈ 4.76% < 5% → false
expect(hasSignificantCostVariance(makeActs([100, 105]))).toBe(false)
// rango = (111-100)/111 ≈ 9.9% > 5% → true
expect(hasSignificantCostVariance(makeActs([100, 111]))).toBe(true)
})
it('no crashea con costos negativos (defensivo)', () => {
expect(() => hasSignificantCostVariance(makeActs([-10, -5, 0]))).not.toThrow()
})
it('todos en cero → false (max=0)', () => {
expect(hasSignificantCostVariance(makeActs([0, 0, 0]))).toBe(false)
})
})
// ─── computeActivityT ────────────────────────────────────────────────────────
describe('computeActivityT — modo relativo', () => {
it('actividad con mayor % → t = 1.0', () => {
const acts = makeActs([300, 100, 50])
const t = computeActivityT(acts[0], acts, 'relative')
expect(t).toBeCloseTo(1.0)
})
it('actividad con menor costo → t < t de la más cara', () => {
const acts = makeActs([300, 100, 50])
const tBarata = computeActivityT(acts[2], acts, 'relative')
const tCara = computeActivityT(acts[0], acts, 'relative')
expect(tBarata).toBeLessThan(tCara)
})
it('t siempre está en [0, 1]', () => {
const acts = makeActs([1000, 500, 100, 10])
for (const act of acts) {
const t = computeActivityT(act, acts, 'relative')
expect(t).toBeGreaterThanOrEqual(0)
expect(t).toBeLessThanOrEqual(1)
}
})
it('lista vacía → t = 0 sin crash', () => {
const act: ActivityCostData = { expectedTotalCost: 100, percentOfTotal: 100 }
expect(computeActivityT(act, [], 'relative')).toBe(0)
})
})
describe('computeActivityT — modo absoluto', () => {
it('actividad más cara → t = 1.0', () => {
const acts = makeActs([100, 200, 300])
const t = computeActivityT(acts[2], acts, 'absolute')
expect(t).toBeCloseTo(1.0)
})
it('actividad más barata → t = 0.0', () => {
const acts = makeActs([100, 200, 300])
const t = computeActivityT(acts[0], acts, 'absolute')
expect(t).toBeCloseTo(0.0)
})
it('actividad del medio → t ≈ 0.5', () => {
const acts = makeActs([100, 200, 300])
const t = computeActivityT(acts[1], acts, 'absolute')
expect(t).toBeCloseTo(0.5)
})
it('un solo precio (max=min) → t = 0.5 (defensivo)', () => {
const acts = makeActs([100, 100])
const t = computeActivityT(acts[0], acts, 'absolute')
expect(t).toBe(0.5)
})
})
// ─── activityColor — función unificada ───────────────────────────────────────
describe('activityColor', () => {
it('SIN varianza significativa → TODOS devuelven NEUTRAL_HEATMAP_COLOR', () => {
const acts = makeActs([100, 100, 100, 100, 100])
for (const act of acts) {
expect(activityColor(act, acts, 'relative')).toBe(NEUTRAL_HEATMAP_COLOR)
expect(activityColor(act, acts, 'absolute')).toBe(NEUTRAL_HEATMAP_COLOR)
}
})
it('CON varianza real modo relativo: la más cara NO es neutra', () => {
const acts = makeActs([500, 100, 10])
const color = activityColor(acts[0], acts, 'relative')
expect(color).not.toBe(NEUTRAL_HEATMAP_COLOR)
})
it('CON varianza real modo relativo: la más cara es roja (#ef4444)', () => {
const acts = makeActs([500, 100, 10])
const colorCara = activityColor(acts[0], acts, 'relative')
expect(colorCara).toBe('#ef4444')
})
it('CON varianza real modo relativo: la más barata es verde (#10b981)', () => {
const acts = makeActs([500, 100, 10])
const colorBarata = activityColor(acts[2], acts, 'relative')
// No necesariamente exactamente verde, pero sí del espectro inferior
// La más barata tiene percentOfTotal bajo / maxPercent bajo → t bajo → color verdoso
expect(colorBarata).not.toBe('#ef4444')
})
it('CON varianza real modo absoluto: la más cara es roja', () => {
const acts = makeActs([1000, 100, 10])
const colorCara = activityColor(acts[0], acts, 'absolute')
expect(colorCara).toBe('#ef4444')
})
it('CON varianza real modo absoluto: la más barata es verde', () => {
const acts = makeActs([1000, 100, 10])
const colorBarata = activityColor(acts[2], acts, 'absolute')
expect(colorBarata).toBe('#10b981')
})
it('edge: costos negativos no crashean', () => {
const acts = makeActs([0, 0, 0])
expect(() => activityColor(acts[0], acts, 'relative')).not.toThrow()
})
it('simple-linear scenario: 5 actividades igual costo → todas neutras', () => {
// Este es el caso de use real de simple-linear.bpmn con costos fijos iguales
const acts = makeActs([100, 100, 100, 100, 100])
const colors = acts.map((a) => activityColor(a, acts, 'relative'))
expect(colors.every((c) => c === NEUTRAL_HEATMAP_COLOR)).toBe(true)
})
it('NEUTRAL_HEATMAP_COLOR es un hex válido y claramente distinto del gradiente', () => {
expect(NEUTRAL_HEATMAP_COLOR).toMatch(/^#[0-9a-f]{6}$/)
expect(NEUTRAL_HEATMAP_COLOR).not.toBe('#10b981') // no es verde
expect(NEUTRAL_HEATMAP_COLOR).not.toBe('#f59e0b') // no es amarillo
expect(NEUTRAL_HEATMAP_COLOR).not.toBe('#ef4444') // no es rojo
})
})

View File

@@ -0,0 +1,220 @@
import { describe, it, expect } from 'vitest'
import { generateCsvContent } from '@/lib/export/csv-export'
import type { Process, Simulation, Resource, SimulationResult } from '@/domain/types'
// ─── Fixtures ─────────────────────────────────────────────────────────────────
function makeProcess(currency: string, extras: Partial<Process> = {}): Process {
return {
id: 'p1', name: 'Proceso de Ventas', clientName: 'Empresa ABC',
bpmnXml: '', currency, overheadPercentage: 0.2,
createdAt: 0, updatedAt: 0, ...extras,
}
}
function makeSimulation(perActivity: SimulationResult['perActivity'] = []): Simulation {
const totalDirect = perActivity.reduce((s, a) => s + a.expectedDirectCost, 0)
const totalIndirect = perActivity.reduce((s, a) => s + a.expectedIndirectCost, 0)
return {
id: 'sim1', processId: 'p1',
executedAt: new Date('2026-05-13T10:00:00Z').getTime(),
result: {
totalCost: totalDirect + totalIndirect,
totalDirectCost: totalDirect,
totalIndirectCost: totalIndirect,
totalTimeMinutes: 90,
perActivity,
perResource: [],
warnings: [],
},
}
}
const mockActivities: SimulationResult['perActivity'] = [
{
activityId: 'a1', bpmnElementId: 'task1', activityName: 'Recibir solicitud',
expectedDirectCost: 500, expectedIndirectCost: 100, expectedTotalCost: 600,
percentOfTotal: 60, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 30, resourceCostBreakdown: [],
},
{
activityId: 'a2', bpmnElementId: 'task2', activityName: 'Análisis de crédito',
expectedDirectCost: 250, expectedIndirectCost: 50, expectedTotalCost: 300,
percentOfTotal: 30, executionProbability: 0.8, expectedExecutions: 0.8,
executionTimeMinutes: 60, resourceCostBreakdown: [],
},
{
activityId: 'a3', bpmnElementId: 'task3', activityName: 'Notificación al cliente',
expectedDirectCost: 83.33, expectedIndirectCost: 16.67, expectedTotalCost: 100,
percentOfTotal: 10, executionProbability: 0.5, expectedExecutions: 0.5,
executionTimeMinutes: 15, resourceCostBreakdown: [],
},
]
const resources: Resource[] = []
// ─── BOM UTF-8 ────────────────────────────────────────────────────────────────
describe('CSV — BOM UTF-8', () => {
it('comienza con BOM UTF-8 (\\uFEFF)', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
expect(csv.charCodeAt(0)).toBe(0xFEFF)
})
it('BOM está presente para PYG también', () => {
const csv = generateCsvContent({ process: makeProcess('PYG'), simulation: makeSimulation(mockActivities), resources })
expect(csv.charCodeAt(0)).toBe(0xFEFF)
})
})
// ─── Separadores ──────────────────────────────────────────────────────────────
describe('CSV — separadores', () => {
it('USD: separador de columnas es coma ","', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
// Los headers deben estar separados por coma
const headerLine = csv.split('\r\n').find((l) => l.includes('Actividad'))!
expect(headerLine).toContain(',')
expect(headerLine).not.toContain(';')
})
it('PYG: separador de columnas es punto y coma ";"', () => {
const csv = generateCsvContent({ process: makeProcess('PYG'), simulation: makeSimulation(mockActivities), resources })
const headerLine = csv.split('\r\n').find((l) => l.includes('Actividad'))!
expect(headerLine).toContain(';')
expect(headerLine).not.toContain(',"Actividad"')
})
it('BRL: separador es punto y coma', () => {
const csv = generateCsvContent({ process: makeProcess('BRL'), simulation: makeSimulation(mockActivities), resources })
const headerLine = csv.split('\r\n').find((l) => l.includes('Actividad'))!
expect(headerLine).toContain(';')
})
it('EUR: separador es punto y coma', () => {
const csv = generateCsvContent({ process: makeProcess('EUR'), simulation: makeSimulation(mockActivities), resources })
const headerLine = csv.split('\r\n').find((l) => l.includes('Actividad'))!
expect(headerLine).toContain(';')
})
})
// ─── Separadores decimales ─────────────────────────────────────────────────────
describe('CSV — separadores decimales', () => {
it('USD: decimal es punto "." → "500.00"', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('500.00')
})
it('PYG: decimal es coma "," → "500,00"', () => {
const csv = generateCsvContent({ process: makeProcess('PYG'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('500,00')
})
it('BRL: decimal es coma', () => {
const csv = generateCsvContent({ process: makeProcess('BRL'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('500,00')
})
})
// ─── Headers ──────────────────────────────────────────────────────────────────
describe('CSV — headers', () => {
it('contiene "Actividad" en los headers', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('Actividad')
})
it('header de costo incluye la moneda: "Costo total (USD)"', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('Costo total (USD)')
})
it('header de costo incluye la moneda: "Costo total (PYG)"', () => {
const csv = generateCsvContent({ process: makeProcess('PYG'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('Costo total (PYG)')
})
it('contiene todos los headers requeridos', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('ID BPMN')
expect(csv).toContain('Tiempo (min)')
expect(csv).toContain('% del total')
expect(csv).toContain('Ejecuciones esperadas')
expect(csv).toContain('Recursos asignados')
})
})
// ─── Cantidad de filas ────────────────────────────────────────────────────────
describe('CSV — cantidad de filas', () => {
it('tiene exactamente N filas de datos + header + metadata', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
// Quitar BOM antes de procesar para no confundir el filtro de comentarios
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
const lines = csvNoBom.split('\r\n').filter((l) => l.trim() && !l.startsWith('#'))
// 1 header + N actividades
expect(lines).toHaveLength(mockActivities.length + 1)
})
it('0 actividades → solo header', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation([]), resources })
const csvNoBom = csv.startsWith('') ? csv.slice(1) : csv
const lines = csvNoBom.split('\r\n').filter((l) => l.trim() && !l.startsWith('#'))
expect(lines).toHaveLength(1) // solo el header
})
})
// ─── Metadata de cabecera ─────────────────────────────────────────────────────
describe('CSV — metadata', () => {
it('la metadata comienza con "# Proceso:"', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('# Proceso: Proceso de Ventas')
})
it('la metadata contiene el nombre del cliente', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('# Cliente: Empresa ABC')
})
it('la metadata contiene el costo total', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources })
expect(csv).toContain('# Costo total (USD)')
})
})
// ─── Caracteres especiales / UTF-8 ───────────────────────────────────────────
describe('CSV — UTF-8 y caracteres especiales', () => {
it('nombres con acentos se preservan correctamente', () => {
const activitiesWithAccents: SimulationResult['perActivity'] = [{
activityId: 'a1', bpmnElementId: 't1',
activityName: 'Revisión y Análisis Crítico',
expectedDirectCost: 100, expectedIndirectCost: 20, expectedTotalCost: 120,
percentOfTotal: 100, executionProbability: 1, expectedExecutions: 1,
executionTimeMinutes: 30, resourceCostBreakdown: [],
}]
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(activitiesWithAccents), resources })
expect(csv).toContain('Revisión y Análisis Crítico')
})
it('nombre del proceso con ñ se preserva', () => {
const process = makeProcess('USD', { name: 'Gestión de la Compañía' })
const csv = generateCsvContent({ process, simulation: makeSimulation([]), resources })
expect(csv).toContain('Gestión de la Compañía')
})
it('actividad con nombre en blanco no rompe el CSV', () => {
const activitiesBlank: SimulationResult['perActivity'] = [{
activityId: 'a1', bpmnElementId: 't1', activityName: '',
expectedDirectCost: 0, expectedIndirectCost: 0, expectedTotalCost: 0,
percentOfTotal: 0, executionProbability: 1, expectedExecutions: 1,
executionTimeMinutes: 0, resourceCostBreakdown: [],
}]
expect(() => generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(activitiesBlank), resources })).not.toThrow()
})
})
// ─── Recursos vacíos ──────────────────────────────────────────────────────────
describe('CSV — recursos vacíos', () => {
it('actividad sin recursos → celda de recursos vacía (no rompe)', () => {
const csv = generateCsvContent({ process: makeProcess('USD'), simulation: makeSimulation(mockActivities), resources: [] })
// No debe crashear y el contenido es válido
expect(csv.length).toBeGreaterThan(0)
expect(csv).toContain('Recibir solicitud')
})
})

View File

@@ -0,0 +1,88 @@
/**
* Medición de tamaños de CSV para los 3 sample BPMNs.
* Este test calcula el tamaño en bytes del CSV generado para cada BPMN.
*/
import { describe, it } 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 { generateCsvContent } from '@/lib/export/csv-export'
import { bpmnTypeToGatewayType } from '@/domain/types'
import type { Activity, GatewayConfig, SimulationInput, Process, Simulation } from '@/domain/types'
function loadBpmn(name: string): string {
return readFileSync(resolve(__dirname, '../../../public/sample-processes', name), 'utf-8')
}
function buildInput(xml: string, directCost: number): SimulationInput {
const graph = parseBpmnXml(xml)
const actEls = extractActivityElements(graph)
const gwEls = extractGatewayElements(graph)
const processId = 'measure-proc'
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: 30, assignedResources: [],
}])
)
const gateways = new Map<string, GatewayConfig>(
gwEls.map((el) => {
const gwType = bpmnTypeToGatewayType(el.gatewayType as any)
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 : 1 / el.outgoing.length,
})),
}]
})
)
return {
processXml: xml, activities, gateways, resources: new Map(),
globalSettings: { currency: 'USD', overheadPercentage: 0.2 },
}
}
describe('Medición de tamaños de CSV — 3 sample BPMNs', () => {
const BPMNS = [
{ file: 'simple-linear.bpmn', name: 'Proceso Lineal Simple', client: 'Demo' },
{ file: 'medium-with-gateways.bpmn', name: 'Aprobación de Crédito', client: 'Banco ABC' },
{ file: 'complex-with-loop.bpmn', name: 'Control de Calidad', client: 'Empresa XYZ' },
]
for (const bpmn of BPMNS) {
it(`${bpmn.file}: mide tamaño del CSV`, () => {
const xml = loadBpmn(bpmn.file)
const result = runSimulation(buildInput(xml, 1000))
const process: Process = {
id: 'p1', name: bpmn.name, clientName: bpmn.client,
bpmnXml: xml, currency: 'USD', overheadPercentage: 0.2,
createdAt: 0, updatedAt: 0,
}
const simulation: Simulation = {
id: 's1', processId: 'p1',
executedAt: new Date('2026-05-13T12:00:00Z').getTime(),
result,
}
const csv = generateCsvContent({ process, simulation, resources: [] })
const bytes = new TextEncoder().encode(csv).length
console.log(`\n📊 ${bpmn.file}`)
console.log(` Actividades: ${result.perActivity.length}`)
console.log(` CSV size: ${bytes} bytes (${(bytes / 1024).toFixed(1)} KB)`)
console.log(` CSV líneas de datos: ${result.perActivity.length} + 1 header + 7 meta`)
console.log(` Costo total: $${result.totalCost.toFixed(2)}`)
// El CSV no debe ser vacío
expect(bytes).toBeGreaterThan(100)
})
}
})

View File

@@ -0,0 +1,166 @@
/**
* Smoke tests del export PDF.
* jsPDF + autotable se mockean porque requieren canvas/DOM completo.
* Se verifica: llamadas al builder, metadata, nombre de archivo, no-throw.
*/
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(3) },
}
// Arrow functions no pueden ser constructoras — usar function() regular
vi.mock('jspdf', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function MockJsPDF(this: any) { return mockDoc }
return { jsPDF: MockJsPDF }
})
vi.mock('jspdf-autotable', () => ({
default: vi.fn(),
}))
// ─── Fixtures ─────────────────────────────────────────────────────────────────
import type { Process, Simulation } 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,
createdAt: 0, updatedAt: 0,
}
const mockSimulation: Simulation = {
id: 'sim1', processId: 'p1',
executedAt: new Date('2026-05-13T10:00:00Z').getTime(),
result: {
totalCost: 1200,
totalDirectCost: 1000,
totalIndirectCost: 200,
totalTimeMinutes: 90,
perActivity: [
{
activityId: 'a1', bpmnElementId: 'task1', activityName: 'Recibir solicitud',
expectedDirectCost: 600, expectedIndirectCost: 120, expectedTotalCost: 720,
percentOfTotal: 60, executionProbability: 1.0, expectedExecutions: 1.0,
executionTimeMinutes: 30, resourceCostBreakdown: [],
},
{
activityId: 'a2', bpmnElementId: 'task2', activityName: 'Procesar',
expectedDirectCost: 400, expectedIndirectCost: 80, expectedTotalCost: 480,
percentOfTotal: 40, executionProbability: 0.8, expectedExecutions: 0.8,
executionTimeMinutes: 60, resourceCostBreakdown: [],
},
],
perResource: [],
warnings: [],
},
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('exportToPdf — smoke tests', () => {
beforeEach(() => { vi.clearAllMocks() })
it('no lanza error con parámetros válidos y heatmap null', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await expect(exportToPdf({
process: mockProcess,
simulation: mockSimulation,
resources: [],
heatmapImageData: null,
})).resolves.not.toThrow()
})
it('llama a doc.addPage al menos una vez (estructura multi-página)', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null })
// El PDF tiene al menos 2 páginas (análisis + tabla)
expect(mockDoc.addPage).toHaveBeenCalled()
})
it('llama a setProperties con metadata correcta', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null })
expect(mockDoc.setProperties).toHaveBeenCalledWith(expect.objectContaining({
title: expect.stringContaining('Proceso de Aprobación de Crédito'),
author: 'Banco XYZ',
subject: 'Simulación de proceso BPMN',
}))
})
it('llama a doc.save con nombre de archivo correcto', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null })
const saveCall = mockDoc.save.mock.calls[0][0] as string
expect(saveCall).toMatch(/proceso-de-aprobacion-de-credito/)
expect(saveCall).toMatch(/banco-xyz/)
expect(saveCall).toMatch(/\.pdf$/)
})
it('NO llama a addImage cuando heatmapImageData es null', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null })
expect(mockDoc.addImage).not.toHaveBeenCalled()
})
it('llama a addImage cuando heatmapImageData está disponible', 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 })
expect(mockDoc.addImage).toHaveBeenCalledWith(fakeDataUrl, 'JPEG', expect.any(Number), expect.any(Number), expect.any(Number), expect.any(Number))
})
it('llama a addPageNumbers (setPage para el footer)', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null })
// addPageNumbers itera setPage para cada página
expect(mockDoc.setPage).toHaveBeenCalled()
})
it('llama a autoTable con head que contiene la moneda', async () => {
const autoTable = (await import('jspdf-autotable')).default as ReturnType<typeof vi.fn>
const { exportToPdf } = await import('@/lib/export/pdf-export')
await exportToPdf({ process: mockProcess, simulation: mockSimulation, resources: [], heatmapImageData: null })
expect(autoTable).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
head: expect.arrayContaining([
expect.arrayContaining([
expect.objectContaining({ content: expect.stringContaining('USD') }),
]),
]),
})
)
})
it('con warnings en el resultado: no lanza error', async () => {
const { exportToPdf } = await import('@/lib/export/pdf-export')
const simWithWarnings = {
...mockSimulation,
result: { ...mockSimulation.result, warnings: ['Loop detectado en task1'] },
}
await expect(exportToPdf({
process: mockProcess, simulation: simWithWarnings, resources: [], heatmapImageData: null,
})).resolves.not.toThrow()
})
})

View File

@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest'
import { slugify, buildFileName } from '@/lib/export/slug'
describe('slugify', () => {
it('"Proceso de Compras" → "proceso-de-compras"', () => {
expect(slugify('Proceso de Compras')).toBe('proceso-de-compras')
})
it('"Análisis Año 2025" → "analisis-ano-2025"', () => {
expect(slugify('Análisis Año 2025')).toBe('analisis-ano-2025')
})
it('elimina la ñ → n', () => {
expect(slugify('Gestión de Compañía')).toBe('gestion-de-compania')
})
it('elimina acentos (á é í ó ú ü)', () => {
expect(slugify('Ú ltimo Análisis Crítico')).toBe('u-ltimo-analisis-critico')
})
it('caracteres especiales → guiones', () => {
expect(slugify('Proceso #1 / Sub-proceso (test)')).toBe('proceso-1-sub-proceso-test')
})
it('múltiples espacios → un solo guión', () => {
expect(slugify('Proceso de ventas')).toBe('proceso-de-ventas')
})
it('string vacío → string vacío', () => {
expect(slugify('')).toBe('')
})
it('solo números → se mantienen', () => {
expect(slugify('2025')).toBe('2025')
})
it('ya en minúsculas sin acentos → sin cambios relevantes', () => {
expect(slugify('proceso-lineal')).toBe('proceso-lineal')
})
it('limita a 60 caracteres', () => {
const long = 'a'.repeat(100)
expect(slugify(long)).toHaveLength(60)
})
})
describe('buildFileName', () => {
// new Date(año, mes0, día) → fecha local sin ambigüedad de timezone
const date = new Date(2026, 4, 13) // 13 de mayo de 2026, local time
it('genera filename con proceso + cliente + fecha', () => {
const name = buildFileName('Proceso de Ventas', 'Banco ABC', date, 'pdf')
expect(name).toBe('proceso-de-ventas_banco-abc_20260513.pdf')
})
it('omite cliente si está vacío', () => {
const name = buildFileName('Proceso de Ventas', '', date, 'csv')
expect(name).toBe('proceso-de-ventas_20260513.csv')
})
it('omite cliente si es undefined', () => {
const name = buildFileName('Proceso', undefined, date, 'pdf')
expect(name).toBe('proceso_20260513.pdf')
})
it('extensión csv correcta', () => {
const name = buildFileName('test', 'cliente', date, 'csv')
expect(name).toMatch(/\.csv$/)
})
it('extensión pdf correcta', () => {
const name = buildFileName('test', 'cliente', date, 'pdf')
expect(name).toMatch(/\.pdf$/)
})
it('nombre con caracteres especiales queda limpio (sin #, paréntesis)', () => {
const name = buildFileName('Análisis #1', 'Empresa S.A.', date, 'csv')
// Verificar solo el slug (sin la extensión que tiene un punto legítimo)
const slug = name.replace(/\.\w+$/, '')
expect(slug).not.toMatch(/[#()]/)
expect(name).toMatch(/analisis/)
})
})

211
tests/lib/format.test.ts Normal file
View File

@@ -0,0 +1,211 @@
import { describe, it, expect } from 'vitest'
import { formatCurrency, formatPercent, formatMinutes, formatSimulationTimestamp } from '@/lib/format'
// ─── formatCurrency ───────────────────────────────────────────────────────────
describe('formatCurrency', () => {
// USD
it('USD: formatea 0 correctamente', () => {
const result = formatCurrency(0, 'USD')
expect(result).toMatch(/0/)
expect(result).toMatch(/\$|USD/)
})
it('USD: formatea 1 con 2 decimales', () => {
const result = formatCurrency(1, 'USD')
expect(result).toMatch(/1[.,]00/)
})
it('USD: formatea 1000 con separador de miles', () => {
const result = formatCurrency(1000, 'USD')
// Debe tener separador (coma o punto) antes de los tres ceros
expect(result).toMatch(/1[.,]000/)
})
it('USD: formatea 1234567 con separadores', () => {
const result = formatCurrency(1234567, 'USD')
expect(result).toMatch(/1/)
expect(result).toMatch(/234/)
expect(result).toMatch(/567/)
})
it('USD: formatea decimales correctamente', () => {
const result = formatCurrency(1234.56, 'USD')
expect(result).toMatch(/1[.,]234/)
expect(result).toMatch(/56/)
})
// PYG — el guaraní no tiene decimales centavos en la práctica
it('PYG: devuelve una cadena con el valor', () => {
const result = formatCurrency(1000000, 'PYG')
expect(result).toBeTruthy()
expect(typeof result).toBe('string')
expect(result.length).toBeGreaterThan(0)
})
it('PYG: 0 formatea sin crash', () => {
expect(() => formatCurrency(0, 'PYG')).not.toThrow()
})
it('PYG: número grande formatea sin crash', () => {
expect(() => formatCurrency(1_234_567_890, 'PYG')).not.toThrow()
const result = formatCurrency(1_234_567_890, 'PYG')
expect(result).toMatch(/1/)
})
// BRL
it('BRL: formatea con símbolo correcto', () => {
const result = formatCurrency(1000, 'BRL')
expect(result).toBeTruthy()
// El símbolo R$ puede aparecer o la abreviatura BRL
expect(result).toMatch(/1/)
})
// EUR
it('EUR: formatea 1234.56 sin crash', () => {
expect(() => formatCurrency(1234.56, 'EUR')).not.toThrow()
const result = formatCurrency(1234.56, 'EUR')
expect(result).toMatch(/1/)
})
// Negativos — el reporte no debe tener valores negativos, pero la función no debe crashear
it('No crashea con valores negativos', () => {
expect(() => formatCurrency(-100, 'USD')).not.toThrow()
const result = formatCurrency(-100, 'USD')
expect(result).toMatch(/-|100/)
})
// Múltiples monedas producen outputs distintos
it('USD y PYG producen outputs distintos para el mismo número', () => {
const usd = formatCurrency(1000, 'USD')
const pyg = formatCurrency(1000, 'PYG')
expect(usd).not.toBe(pyg)
})
})
// ─── formatMinutes ────────────────────────────────────────────────────────────
describe('formatMinutes', () => {
it('5 min → "5 min"', () => {
expect(formatMinutes(5)).toBe('5 min')
})
it('30 min → "30 min"', () => {
expect(formatMinutes(30)).toBe('30 min')
})
it('59 min → "59 min" (aún no llega a 1h)', () => {
expect(formatMinutes(59)).toBe('59 min')
})
it('60 min → "1h" (exactamente 1 hora)', () => {
expect(formatMinutes(60)).toBe('1h')
})
it('90 min → "1h 30min"', () => {
expect(formatMinutes(90)).toBe('1h 30min')
})
it('125 min → "2h 5min"', () => {
expect(formatMinutes(125)).toBe('2h 5min')
})
it('120 min → "2h" (horas exactas, sin fracción)', () => {
expect(formatMinutes(120)).toBe('2h')
})
it('1440 min → "24h" (un día completo)', () => {
expect(formatMinutes(1440)).toBe('24h')
})
it('0 min → "0 min"', () => {
expect(formatMinutes(0)).toBe('0 min')
})
it('No crashea con valores decimales', () => {
expect(() => formatMinutes(90.7)).not.toThrow()
})
})
// ─── formatPercent ────────────────────────────────────────────────────────────
// Nota: el locale es-PY usa coma como separador decimal → "23,4%" no "23.4%".
// Los tests verifican estructura (contiene el valor y el símbolo) sin asumir separador.
describe('formatPercent', () => {
it('23.4 → contiene "23" y "4" y "%" con 1 decimal', () => {
const result = formatPercent(23.4)
expect(result).toContain('23')
expect(result).toContain('4')
expect(result).toContain('%')
})
it('100 → contiene "100" y "%" con 1 decimal', () => {
const result = formatPercent(100)
expect(result).toContain('100')
expect(result).toContain('%')
})
it('0 → contiene "0" y "%" sin crash', () => {
const result = formatPercent(0)
expect(result).toContain('0')
expect(result).toContain('%')
})
it('el locale es-PY usa coma decimal: 23.4 → "23,4%"', () => {
// Documenta el comportamiento real del locale configurado
expect(formatPercent(23.4)).toBe('23,4%')
})
it('100% con 0 decimales → "100%"', () => {
expect(formatPercent(100, 0)).toBe('100%')
})
it('20 con 0 decimales → "20%"', () => {
expect(formatPercent(20, 0)).toBe('20%')
})
})
// ─── formatSimulationTimestamp ────────────────────────────────────────────────
describe('formatSimulationTimestamp', () => {
// Timestamp fijo para reproducibilidad: 13 de mayo de 2026, 09:45 UTC-4 (PY hora de verano)
// Usamos una fecha conocida y verificamos la estructura del output sin depender de TZ del CI.
const KNOWN_TS = new Date('2026-05-13T13:45:00Z').getTime() // UTC
it('devuelve objeto con date y time strings no vacíos', () => {
const { date, time } = formatSimulationTimestamp(KNOWN_TS)
expect(date.length).toBeGreaterThan(0)
expect(time.length).toBeGreaterThan(0)
})
it('date contiene el año 2026', () => {
const { date } = formatSimulationTimestamp(KNOWN_TS)
expect(date).toContain('2026')
})
it('date contiene el número 13 (día)', () => {
const { date } = formatSimulationTimestamp(KNOWN_TS)
expect(date).toContain('13')
})
it('date contiene "mayo" (nombre del mes en es-PY)', () => {
const { date } = formatSimulationTimestamp(KNOWN_TS)
expect(date).toMatch(/mayo/i)
})
it('time tiene formato HH:mm (dos pares de dígitos separados por ":")', () => {
const { time } = formatSimulationTimestamp(KNOWN_TS)
expect(time).toMatch(/\d{1,2}:\d{2}/)
})
it('no crashea con timestamp 0 (epoch)', () => {
expect(() => formatSimulationTimestamp(0)).not.toThrow()
const { date, time } = formatSimulationTimestamp(0)
expect(date.length).toBeGreaterThan(0)
expect(time.length).toBeGreaterThan(0)
})
it('timestamps distintos producen salidas distintas', () => {
const ts1 = new Date('2026-01-01T10:00:00Z').getTime()
const ts2 = new Date('2026-06-15T18:30:00Z').getTime()
const r1 = formatSimulationTimestamp(ts1)
const r2 = formatSimulationTimestamp(ts2)
expect(r1.date).not.toBe(r2.date)
})
})

8
tests/setup.ts Normal file
View File

@@ -0,0 +1,8 @@
// Parchear IndexedDB antes de que Dexie intente abrirla
import 'fake-indexeddb/auto'
import '@testing-library/jest-dom'
// jsdom no implementa scrollIntoView — mock para que no rompa tests de tabla
if (typeof HTMLElement !== 'undefined') {
HTMLElement.prototype.scrollIntoView = () => {}
}