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:
251
tests/integration/bpmn-parser.test.ts
Normal file
251
tests/integration/bpmn-parser.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user