Files
inq-roi-simulador-web/tests/integration/gateway-validation.test.ts
Marcos Benítez bed161c92a 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>
2026-05-14 01:58:40 -03:00

158 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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()
})
})