159 lines
7.0 KiB
TypeScript
159 lines
7.0 KiB
TypeScript
/**
|
||
* 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: [],
|
||
automatable: false, automatedCostFixed: 0, automatedTimeMinutes: 0,
|
||
}])
|
||
)
|
||
|
||
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()
|
||
})
|
||
})
|