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