Sprint 3 completo — 7 etapas: - Etapa 1-2: biblioteca de procesos con agrupación por cliente (Dexie v4, LibraryPage, library-store, groupId/tags en Process) - Etapa 3: modal de configuración global por proceso (horizonte, frecuencia, inversión, moneda) con validación y persistencia - Etapa 4: panel de recursos rediseñado con soporte de unidades y minutos de utilización (utilizationMinutes + units) - Etapa 5: recursos en escenario automatizado — motor de simulación, UI de edición en ActivityPanel, comparación AS-IS/TO-BE en reporte - Etapa 6: PDF recursos automatizados, automatedAssignedResources requerido, 561 tests pasando - Etapa 7: identidad de marca — AppHeader gradiente naranja/magenta con logo InQuality en 5 páginas, favicon con logo real, logo/marca navegan a home Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
234 lines
9.8 KiB
TypeScript
234 lines
9.8 KiB
TypeScript
/**
|
|
* Test integrador del flujo completo del Sprint 1.
|
|
*
|
|
* Verifica que las piezas del dominio conectan correctamente:
|
|
* parseBpmnXml → extractActivityElements → runSimulation (actual + automated)
|
|
* → calculateRoi → KPIs correctos
|
|
*
|
|
* BPMN base: medium-with-gateways (Aprobación de crédito)
|
|
* — 11 actividades, 3 gateways (2 XOR + 1 AND), 5 flujos condicionales
|
|
*
|
|
* Costos usados (deliberadamente simples para assertions deterministas):
|
|
* - Todas las actividades: directCostFixed=$200, executionTimeMinutes=60
|
|
* - Automatable: solo task_recv y task_score (las más caras por prob alta)
|
|
* - automatedCostFixed=$20 (90% de ahorro), automatedTimeMinutes=5
|
|
* - annualFrequency=1200 (100 ejecuciones/mes), horizonte=3 años, inversión=$50.000
|
|
*/
|
|
|
|
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 { calculateRoi } from '@/domain/roi'
|
|
import { bpmnTypeToGatewayType } from '@/domain/types'
|
|
import type { Activity, GatewayConfig, SimulationInput } from '@/domain/types'
|
|
|
|
const MEDIUM_XML = readFileSync(
|
|
resolve(__dirname, '../../public/sample-processes/medium-with-gateways.bpmn'),
|
|
'utf-8'
|
|
)
|
|
|
|
// Actividades automatable y sus costos automatizados
|
|
const AUTOMATABLE_IDS = new Set(['task_recv', 'task_score'])
|
|
const AUTOMATED_COST = 20
|
|
const AUTOMATED_TIME = 5
|
|
|
|
function buildActivities(xml: string): Map<string, Activity> {
|
|
const graph = parseBpmnXml(xml)
|
|
const elements = extractActivityElements(graph)
|
|
return new Map(
|
|
elements.map((el) => [el.bpmnElementId, {
|
|
id: uuidv4(),
|
|
processId: 'sprint1-test',
|
|
bpmnElementId: el.bpmnElementId,
|
|
name: el.name,
|
|
type: el.type,
|
|
directCostFixed: 200,
|
|
executionTimeMinutes: 60,
|
|
assignedResources: [],
|
|
automatable: AUTOMATABLE_IDS.has(el.bpmnElementId),
|
|
automatedCostFixed: AUTOMATABLE_IDS.has(el.bpmnElementId) ? AUTOMATED_COST : 0,
|
|
automatedTimeMinutes: AUTOMATABLE_IDS.has(el.bpmnElementId) ? AUTOMATED_TIME : 0,
|
|
automatedAssignedResources: [],
|
|
}])
|
|
)
|
|
}
|
|
|
|
function buildGateways(xml: string): Map<string, GatewayConfig> {
|
|
const graph = parseBpmnXml(xml)
|
|
const elements = extractGatewayElements(graph)
|
|
return new Map(
|
|
elements.map((el) => {
|
|
const gwType = bpmnTypeToGatewayType(el.gatewayType as any)
|
|
const n = el.outgoing.length
|
|
return [el.bpmnElementId, {
|
|
id: uuidv4(),
|
|
processId: 'sprint1-test',
|
|
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)),
|
|
})),
|
|
}]
|
|
})
|
|
)
|
|
}
|
|
|
|
const BASE_INPUT: Omit<SimulationInput, 'scenario'> = {
|
|
processXml: MEDIUM_XML,
|
|
activities: buildActivities(MEDIUM_XML),
|
|
gateways: buildGateways(MEDIUM_XML),
|
|
resources: new Map(),
|
|
globalSettings: { currency: 'USD', overheadPercentage: 0.2 },
|
|
}
|
|
|
|
const VOLUMETRY = {
|
|
annualFrequency: 1200,
|
|
analysisHorizonYears: 3,
|
|
automationInvestment: 50_000,
|
|
}
|
|
|
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Sprint 1 — flujo integrado: simulación dual + ROI', () => {
|
|
const resultActual = runSimulation({ ...BASE_INPUT, scenario: 'actual' })
|
|
const resultAutomated = runSimulation({ ...BASE_INPUT, scenario: 'automated' })
|
|
|
|
// ─── Simulación actual ──────────────────────────────────────────────────
|
|
|
|
it('simulación actual produce resultado no nulo con actividades reales', () => {
|
|
expect(resultActual.totalCost).toBeGreaterThan(0)
|
|
expect(resultActual.perActivity.length).toBeGreaterThan(0)
|
|
expect(resultActual.warnings).toBeDefined()
|
|
})
|
|
|
|
it('simulación actual: task_recv tiene probabilidad 1.0 (primer nodo del proceso)', () => {
|
|
const recv = resultActual.perActivity.find((a) => a.bpmnElementId === 'task_recv')
|
|
expect(recv).toBeDefined()
|
|
expect(recv!.executionProbability).toBeCloseTo(1.0)
|
|
})
|
|
|
|
it('simulación actual: las actividades con costos tienen expectedDirectCost > 0', () => {
|
|
for (const act of resultActual.perActivity) {
|
|
expect(Number.isNaN(act.expectedDirectCost)).toBe(false)
|
|
expect(act.expectedDirectCost).toBeGreaterThanOrEqual(0)
|
|
}
|
|
})
|
|
|
|
// ─── Simulación automatizada ────────────────────────────────────────────
|
|
|
|
it('simulación automatizada produce totalCost menor que actual', () => {
|
|
// Hay actividades automatable con costo menor → total debe bajar
|
|
expect(resultAutomated.totalCost).toBeLessThan(resultActual.totalCost)
|
|
})
|
|
|
|
it('probabilidades de ejecución son idénticas entre escenarios', () => {
|
|
for (const actActual of resultActual.perActivity) {
|
|
const actAuto = resultAutomated.perActivity.find(
|
|
(a) => a.bpmnElementId === actActual.bpmnElementId
|
|
)
|
|
expect(actAuto).toBeDefined()
|
|
expect(actAuto!.executionProbability).toBeCloseTo(actActual.executionProbability, 5)
|
|
}
|
|
})
|
|
|
|
it('actividades automatable tienen expectedDirectCost menor en escenario automatizado', () => {
|
|
for (const bpmnElementId of AUTOMATABLE_IDS) {
|
|
const actual = resultActual.perActivity.find((a) => a.bpmnElementId === bpmnElementId)
|
|
const automated = resultAutomated.perActivity.find((a) => a.bpmnElementId === bpmnElementId)
|
|
if (!actual || !automated) continue // podría no aparecer si prob=0
|
|
expect(automated.expectedDirectCost).toBeLessThan(actual.expectedDirectCost)
|
|
}
|
|
})
|
|
|
|
it('actividades no automatable tienen el mismo costo en ambos escenarios', () => {
|
|
for (const act of resultActual.perActivity) {
|
|
if (AUTOMATABLE_IDS.has(act.bpmnElementId)) continue
|
|
const auto = resultAutomated.perActivity.find((a) => a.bpmnElementId === act.bpmnElementId)!
|
|
expect(auto.expectedDirectCost).toBeCloseTo(act.expectedDirectCost, 5)
|
|
}
|
|
})
|
|
|
|
it('ningún campo numérico del resultado automatizado es NaN', () => {
|
|
expect(Number.isNaN(resultAutomated.totalCost)).toBe(false)
|
|
expect(Number.isNaN(resultAutomated.totalDirectCost)).toBe(false)
|
|
expect(Number.isNaN(resultAutomated.totalIndirectCost)).toBe(false)
|
|
for (const act of resultAutomated.perActivity) {
|
|
expect(Number.isNaN(act.expectedDirectCost)).toBe(false)
|
|
expect(Number.isNaN(act.expectedTotalCost)).toBe(false)
|
|
expect(Number.isNaN(act.percentOfTotal)).toBe(false)
|
|
}
|
|
})
|
|
|
|
// ─── calculateRoi conecta con los resultados de simulación ─────────────
|
|
|
|
it('calculateRoi recibe costos de ambas simulaciones y devuelve KPIs válidos', () => {
|
|
const roi = calculateRoi({
|
|
costPerExecutionActual: resultActual.totalCost,
|
|
costPerExecutionAutomated: resultAutomated.totalCost,
|
|
...VOLUMETRY,
|
|
})
|
|
|
|
// Ahorro positivo (automated < actual)
|
|
expect(roi.savingsPerExecution).toBeGreaterThan(0)
|
|
expect(roi.annualSavings).toBeGreaterThan(0)
|
|
|
|
// Payback finito y positivo
|
|
expect(roi.paybackMonths).toBeGreaterThan(0)
|
|
expect(Number.isFinite(roi.paybackMonths)).toBe(true)
|
|
|
|
// Con inversión $50.000 y ahorro anual razonable, debería pagar dentro del horizonte de 3 años
|
|
// (esto depende de los costos calculados, pero verificamos que es un número razonable)
|
|
expect(roi.paybackYears).toBeGreaterThan(0)
|
|
|
|
// ROI anualizado positivo (ahorro > 0, inversión > 0)
|
|
expect(roi.roiAnnualPercent).toBeGreaterThan(0)
|
|
expect(Number.isNaN(roi.roiAnnualPercent)).toBe(false)
|
|
expect(Number.isNaN(roi.cumulativeSavingsHorizon)).toBe(false)
|
|
})
|
|
|
|
it('ahorro acumulado a 3 años supera la inversión si hay ahorro positivo suficiente', () => {
|
|
const roi = calculateRoi({
|
|
costPerExecutionActual: resultActual.totalCost,
|
|
costPerExecutionAutomated: resultAutomated.totalCost,
|
|
...VOLUMETRY,
|
|
})
|
|
|
|
if (roi.annualSavings * VOLUMETRY.analysisHorizonYears > VOLUMETRY.automationInvestment) {
|
|
expect(roi.cumulativeSavingsHorizon).toBeGreaterThan(0)
|
|
expect(roi.breaksEvenInHorizon).toBe(true)
|
|
} else {
|
|
// Si el ahorro es menor que la inversión en 3 años, el test es informativo
|
|
expect(roi.cumulativeSavingsHorizon).toBeDefined()
|
|
}
|
|
})
|
|
|
|
// ─── Transparencia metodológica: actividades automatable con costo cero ─
|
|
|
|
it('detecta actividades automatable con automatedCostFixed=0 para transparencia', () => {
|
|
// En nuestro fixture solo task_recv y task_score son automatable con costo > 0.
|
|
// Si alguien marca una actividad como automatable pero no pone costo,
|
|
// el resultado automatizado de esa actividad debe tener cost=0.
|
|
const activities = buildActivities(MEDIUM_XML)
|
|
|
|
// Forzar una actividad automatable con costo cero (simula campo no configurado)
|
|
const taskValid = activities.get('task_valid')
|
|
if (taskValid) {
|
|
activities.set('task_valid', { ...taskValid, automatable: true, automatedCostFixed: 0, automatedTimeMinutes: 0 })
|
|
}
|
|
|
|
const result = runSimulation({ ...BASE_INPUT, activities, scenario: 'automated' })
|
|
const validResult = result.perActivity.find((a) => a.bpmnElementId === 'task_valid')
|
|
|
|
if (validResult && validResult.executionProbability > 0) {
|
|
// automatable=true pero costos en 0 → contribuye con $0 al total automatizado
|
|
expect(validResult.expectedDirectCost).toBe(0)
|
|
expect(Number.isNaN(validResult.expectedDirectCost)).toBe(false)
|
|
}
|
|
})
|
|
})
|