Antes de la última revisión de etapa 4. Sprint 1. Previo al ajuste de look n feel de app, reportes, nombre y colores.
This commit is contained in:
161
tests/domain/roi.test.ts
Normal file
161
tests/domain/roi.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Tests del módulo domain/roi.ts.
|
||||
* Cubren: happy path, edge cases de inversión cero, ahorro cero/negativo,
|
||||
* división por cero, escenarios de largo plazo y corto plazo.
|
||||
* Todas las fórmulas son nominales simples (ADR-014).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { calculateRoi } from '@/domain/roi'
|
||||
import type { RoiInput } from '@/domain/roi'
|
||||
|
||||
// ─── Fixture base ─────────────────────────────────────────────────────────────
|
||||
|
||||
function baseInput(overrides: Partial<RoiInput> = {}): RoiInput {
|
||||
return {
|
||||
costPerExecutionActual: 100,
|
||||
costPerExecutionAutomated: 20,
|
||||
annualFrequency: 1000,
|
||||
analysisHorizonYears: 3,
|
||||
automationInvestment: 50_000,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Happy path ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('calculateRoi — happy path', () => {
|
||||
it('calcula ahorro por ejecución correctamente', () => {
|
||||
const r = calculateRoi(baseInput())
|
||||
expect(r.savingsPerExecution).toBeCloseTo(80)
|
||||
})
|
||||
|
||||
it('calcula ahorro por ejecución en %', () => {
|
||||
const r = calculateRoi(baseInput())
|
||||
expect(r.savingsPerExecutionPercent).toBeCloseTo(80) // 80/100 = 80%
|
||||
})
|
||||
|
||||
it('calcula ahorro anual = ahorro/ejecución × frecuencia', () => {
|
||||
const r = calculateRoi(baseInput())
|
||||
expect(r.annualSavings).toBeCloseTo(80_000) // 80 × 1000
|
||||
})
|
||||
|
||||
it('calcula payback en meses correctamente', () => {
|
||||
const r = calculateRoi(baseInput())
|
||||
// $50.000 / $80.000/año × 12 = 7.5 meses
|
||||
expect(r.paybackMonths).toBeCloseTo(7.5)
|
||||
})
|
||||
|
||||
it('calcula payback en años', () => {
|
||||
const r = calculateRoi(baseInput())
|
||||
expect(r.paybackYears).toBeCloseTo(0.625)
|
||||
})
|
||||
|
||||
it('calcula ahorro acumulado neto al horizonte', () => {
|
||||
const r = calculateRoi(baseInput())
|
||||
// ($80.000 × 3 años) - $50.000 = $190.000
|
||||
expect(r.cumulativeSavingsHorizon).toBeCloseTo(190_000)
|
||||
})
|
||||
|
||||
it('el payback dentro del horizonte es true cuando paybackYears < horizonYears', () => {
|
||||
const r = calculateRoi(baseInput()) // payback ~0.625 < 3 años
|
||||
expect(r.breaksEvenInHorizon).toBe(true)
|
||||
})
|
||||
|
||||
it('calcula ROI anualizado %', () => {
|
||||
const r = calculateRoi(baseInput())
|
||||
// ($80.000 / $50.000) × 100 = 160%
|
||||
expect(r.roiAnnualPercent).toBeCloseTo(160)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Edge case: inversión cero con ahorro positivo ────────────────────────────
|
||||
|
||||
describe('calculateRoi — inversión cero', () => {
|
||||
it('payback es 0 meses (recuperación instantánea)', () => {
|
||||
const r = calculateRoi(baseInput({ automationInvestment: 0 }))
|
||||
expect(r.paybackMonths).toBe(0)
|
||||
expect(r.paybackYears).toBe(0)
|
||||
})
|
||||
|
||||
it('breaksEvenInHorizon es true', () => {
|
||||
const r = calculateRoi(baseInput({ automationInvestment: 0 }))
|
||||
expect(r.breaksEvenInHorizon).toBe(true)
|
||||
})
|
||||
|
||||
it('roiAnnualPercent es Infinity', () => {
|
||||
const r = calculateRoi(baseInput({ automationInvestment: 0 }))
|
||||
expect(r.roiAnnualPercent).toBe(Infinity)
|
||||
})
|
||||
|
||||
it('inversión cero con ahorro cero: roiAnnualPercent es 0', () => {
|
||||
const r = calculateRoi(baseInput({
|
||||
automationInvestment: 0,
|
||||
costPerExecutionActual: 100,
|
||||
costPerExecutionAutomated: 100,
|
||||
}))
|
||||
expect(r.roiAnnualPercent).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Edge case: ahorro cero o negativo ────────────────────────────────────────
|
||||
|
||||
describe('calculateRoi — ahorro negativo o cero', () => {
|
||||
it('si el costo automatizado = costo actual, ahorro = 0', () => {
|
||||
const r = calculateRoi(baseInput({ costPerExecutionAutomated: 100 }))
|
||||
expect(r.savingsPerExecution).toBeCloseTo(0)
|
||||
expect(r.annualSavings).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('ahorro cero → payback Infinity', () => {
|
||||
const r = calculateRoi(baseInput({ costPerExecutionAutomated: 100 }))
|
||||
expect(r.paybackMonths).toBe(Infinity)
|
||||
expect(r.paybackYears).toBe(Infinity)
|
||||
})
|
||||
|
||||
it('ahorro cero → breaksEvenInHorizon false', () => {
|
||||
const r = calculateRoi(baseInput({ costPerExecutionAutomated: 100 }))
|
||||
expect(r.breaksEvenInHorizon).toBe(false)
|
||||
})
|
||||
|
||||
it('ahorro negativo (automatizado más caro): payback Infinity', () => {
|
||||
const r = calculateRoi(baseInput({ costPerExecutionAutomated: 150 }))
|
||||
expect(r.annualSavings).toBeLessThan(0)
|
||||
expect(r.paybackMonths).toBe(Infinity)
|
||||
expect(r.breaksEvenInHorizon).toBe(false)
|
||||
})
|
||||
|
||||
it('ahorro negativo: cumulativeSavings también es negativo', () => {
|
||||
const r = calculateRoi(baseInput({ costPerExecutionAutomated: 150 }))
|
||||
expect(r.cumulativeSavingsHorizon).toBeLessThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Edge case: costo actual cero (evitar div/0) ──────────────────────────────
|
||||
|
||||
describe('calculateRoi — costo actual cero', () => {
|
||||
it('savingsPerExecutionPercent es 0, no NaN ni Infinity', () => {
|
||||
const r = calculateRoi(baseInput({
|
||||
costPerExecutionActual: 0,
|
||||
costPerExecutionAutomated: 0,
|
||||
}))
|
||||
expect(r.savingsPerExecutionPercent).toBe(0)
|
||||
expect(Number.isFinite(r.savingsPerExecutionPercent)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Escenario de largo plazo (horizonte 10 años) ─────────────────────────────
|
||||
|
||||
describe('calculateRoi — horizonte extendido', () => {
|
||||
it('payback fuera del horizonte corto → breaksEvenInHorizon false', () => {
|
||||
// savingsPerExec=10, freq=1000 → annualSavings=$10.000; payback=$500.000/$10.000=50 años
|
||||
const r = calculateRoi(baseInput({
|
||||
automationInvestment: 500_000,
|
||||
costPerExecutionActual: 100,
|
||||
costPerExecutionAutomated: 90,
|
||||
annualFrequency: 1000,
|
||||
}))
|
||||
expect(r.paybackYears).toBeGreaterThan(3)
|
||||
expect(r.breaksEvenInHorizon).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -89,6 +89,9 @@ describe('runSimulation', () => {
|
||||
directCostFixed: directCost,
|
||||
executionTimeMinutes: minutes,
|
||||
assignedResources: resources,
|
||||
automatable: false,
|
||||
automatedCostFixed: 0,
|
||||
automatedTimeMinutes: 0,
|
||||
})
|
||||
|
||||
it('calcula costo total sin recursos — proceso lineal', () => {
|
||||
@@ -208,3 +211,182 @@ describe('runSimulation', () => {
|
||||
expect(result.perActivity[1].bpmnElementId).toBe('taskA')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Sprint 1: escenario 'automated' + actividades automatizables ─────────────
|
||||
|
||||
describe('runSimulation — scenario automated', () => {
|
||||
const makeAutoActivity = (
|
||||
bpmnElementId: string,
|
||||
opts: {
|
||||
directCost?: number; directTime?: number
|
||||
automatable?: boolean; automatedCost?: number; automatedTime?: number
|
||||
} = {}
|
||||
): Activity => ({
|
||||
id: `act-${bpmnElementId}`,
|
||||
processId: 'proc1',
|
||||
bpmnElementId,
|
||||
name: bpmnElementId,
|
||||
type: 'task',
|
||||
directCostFixed: opts.directCost ?? 100,
|
||||
executionTimeMinutes: opts.directTime ?? 60,
|
||||
assignedResources: [],
|
||||
automatable: opts.automatable ?? false,
|
||||
automatedCostFixed: opts.automatedCost ?? 0,
|
||||
automatedTimeMinutes: opts.automatedTime ?? 0,
|
||||
})
|
||||
|
||||
// ─── scenario=automated con costos en cero ────────────────────────────────
|
||||
|
||||
it('scenario=automated con automatedCostFixed=0 y automatedTimeMinutes=0 devuelve cost=0 time=0 sin NaN', () => {
|
||||
const act = makeAutoActivity('taskA', {
|
||||
directCost: 500, directTime: 60,
|
||||
automatable: true, automatedCost: 0, automatedTime: 0,
|
||||
})
|
||||
|
||||
const result = runSimulation({
|
||||
processXml: LINEAR_BPMN,
|
||||
activities: new Map([['taskA', act], ['taskB', makeAutoActivity('taskB')]]),
|
||||
gateways: new Map(),
|
||||
resources: new Map(),
|
||||
globalSettings: { currency: 'USD', overheadPercentage: 0 },
|
||||
scenario: 'automated',
|
||||
})
|
||||
|
||||
const actResult = result.perActivity.find((a) => a.bpmnElementId === 'taskA')!
|
||||
expect(actResult).toBeDefined()
|
||||
expect(actResult.expectedDirectCost).toBe(0)
|
||||
expect(actResult.executionTimeMinutes).toBe(0)
|
||||
|
||||
// Verificación explícita de ausencia de NaN en todos los campos numéricos
|
||||
expect(Number.isNaN(actResult.expectedDirectCost)).toBe(false)
|
||||
expect(Number.isNaN(actResult.expectedIndirectCost)).toBe(false)
|
||||
expect(Number.isNaN(actResult.expectedTotalCost)).toBe(false)
|
||||
expect(Number.isNaN(actResult.percentOfTotal)).toBe(false)
|
||||
expect(Number.isNaN(result.totalCost)).toBe(false)
|
||||
})
|
||||
|
||||
it('actividad NO automatable mantiene costos originales en scenario=automated', () => {
|
||||
const act = makeAutoActivity('taskA', {
|
||||
directCost: 200, directTime: 30,
|
||||
automatable: false, automatedCost: 0, automatedTime: 0,
|
||||
})
|
||||
|
||||
const result = runSimulation({
|
||||
processXml: LINEAR_BPMN,
|
||||
activities: new Map([['taskA', act], ['taskB', makeAutoActivity('taskB')]]),
|
||||
gateways: new Map(),
|
||||
resources: new Map(),
|
||||
globalSettings: { currency: 'USD', overheadPercentage: 0 },
|
||||
scenario: 'automated',
|
||||
})
|
||||
|
||||
const actResult = result.perActivity.find((a) => a.bpmnElementId === 'taskA')!
|
||||
// automatable=false → usa costo original
|
||||
expect(actResult.expectedDirectCost).toBeCloseTo(200)
|
||||
expect(actResult.executionTimeMinutes).toBe(30)
|
||||
})
|
||||
|
||||
// ─── La probabilidad de ejecución NO depende del scenario ────────────────
|
||||
|
||||
it('probabilidad de ejecución NO cambia entre scenario=actual y scenario=automated', () => {
|
||||
// Usamos el BPMN XOR: taskB tiene prob 0.7, taskC tiene prob 0.3
|
||||
const XOR_BPMN_LOCAL = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" targetNamespace="test">
|
||||
<process id="p" isExecutable="false">
|
||||
<startEvent id="start"><outgoing>f1</outgoing></startEvent>
|
||||
<task id="taskA" name="A"><incoming>f1</incoming><outgoing>f2</outgoing></task>
|
||||
<exclusiveGateway id="gw1"><incoming>f2</incoming><outgoing>f3</outgoing><outgoing>f4</outgoing></exclusiveGateway>
|
||||
<task id="taskB" name="B"><incoming>f3</incoming><outgoing>f5</outgoing></task>
|
||||
<task id="taskC" name="C"><incoming>f4</incoming><outgoing>f6</outgoing></task>
|
||||
<endEvent id="end"><incoming>f5</incoming><incoming>f6</incoming></endEvent>
|
||||
<sequenceFlow id="f1" sourceRef="start" targetRef="taskA"/>
|
||||
<sequenceFlow id="f2" sourceRef="taskA" targetRef="gw1"/>
|
||||
<sequenceFlow id="f3" sourceRef="gw1" targetRef="taskB"/>
|
||||
<sequenceFlow id="f4" sourceRef="gw1" targetRef="taskC"/>
|
||||
<sequenceFlow id="f5" sourceRef="taskB" targetRef="end"/>
|
||||
<sequenceFlow id="f6" sourceRef="taskC" targetRef="end"/>
|
||||
</process>
|
||||
</definitions>`
|
||||
|
||||
const activities = new Map([
|
||||
['taskA', makeAutoActivity('taskA', { automatable: true, automatedCost: 5, automatedTime: 2 })],
|
||||
['taskB', makeAutoActivity('taskB', { automatable: true, automatedCost: 0, automatedTime: 0 })],
|
||||
['taskC', makeAutoActivity('taskC', { automatable: false })],
|
||||
])
|
||||
|
||||
const gateways = new Map([['gw1', {
|
||||
id: 'gw1', processId: 'proc1', bpmnElementId: 'gw1', gatewayType: 'exclusive' as const,
|
||||
branches: [
|
||||
{ flowId: 'f3', targetElementId: 'taskB', probability: 0.7 },
|
||||
{ flowId: 'f4', targetElementId: 'taskC', probability: 0.3 },
|
||||
],
|
||||
}]])
|
||||
|
||||
const baseInput = {
|
||||
processXml: XOR_BPMN_LOCAL,
|
||||
activities,
|
||||
gateways,
|
||||
resources: new Map(),
|
||||
globalSettings: { currency: 'USD', overheadPercentage: 0 },
|
||||
}
|
||||
|
||||
const resultActual = runSimulation({ ...baseInput, scenario: 'actual' })
|
||||
const resultAutomated = runSimulation({ ...baseInput, scenario: 'automated' })
|
||||
|
||||
// taskA tiene prob 1.0 en ambos
|
||||
const aActual = resultActual.perActivity.find((a) => a.bpmnElementId === 'taskA')!
|
||||
const aAuto = resultAutomated.perActivity.find((a) => a.bpmnElementId === 'taskA')!
|
||||
expect(aActual.executionProbability).toBeCloseTo(1.0)
|
||||
expect(aAuto.executionProbability).toBeCloseTo(1.0)
|
||||
|
||||
// taskB tiene prob 0.7 en ambos (el scenario no cambia el grafo)
|
||||
const bActual = resultActual.perActivity.find((a) => a.bpmnElementId === 'taskB')!
|
||||
const bAuto = resultAutomated.perActivity.find((a) => a.bpmnElementId === 'taskB')!
|
||||
expect(bActual.executionProbability).toBeCloseTo(0.7)
|
||||
expect(bAuto.executionProbability).toBeCloseTo(0.7)
|
||||
|
||||
// taskC tiene prob 0.3 en ambos
|
||||
const cActual = resultActual.perActivity.find((a) => a.bpmnElementId === 'taskC')!
|
||||
const cAuto = resultAutomated.perActivity.find((a) => a.bpmnElementId === 'taskC')!
|
||||
expect(cActual.executionProbability).toBeCloseTo(0.3)
|
||||
expect(cAuto.executionProbability).toBeCloseTo(0.3)
|
||||
|
||||
// Los costos SÍ cambian para actividades automatable=true
|
||||
// taskB en actual: directCostFixed=100; en automated: automatedCostFixed=0
|
||||
expect(bActual.expectedDirectCost).toBeGreaterThan(0)
|
||||
expect(bAuto.expectedDirectCost).toBe(0)
|
||||
|
||||
// taskC no es automatable → costos iguales en ambos
|
||||
expect(cActual.expectedDirectCost).toBeCloseTo(cAuto.expectedDirectCost)
|
||||
})
|
||||
|
||||
it('NaN no aparece en ningún campo cuando TODOS los costos son cero', () => {
|
||||
const activities = new Map([
|
||||
['taskA', makeAutoActivity('taskA', { directCost: 0, directTime: 0, automatable: true, automatedCost: 0, automatedTime: 0 })],
|
||||
['taskB', makeAutoActivity('taskB', { directCost: 0, directTime: 0, automatable: true, automatedCost: 0, automatedTime: 0 })],
|
||||
])
|
||||
|
||||
for (const scenario of ['actual', 'automated'] as const) {
|
||||
const result = runSimulation({
|
||||
processXml: LINEAR_BPMN,
|
||||
activities,
|
||||
gateways: new Map(),
|
||||
resources: new Map(),
|
||||
globalSettings: { currency: 'USD', overheadPercentage: 0.2 },
|
||||
scenario,
|
||||
})
|
||||
|
||||
expect(Number.isNaN(result.totalCost)).toBe(false)
|
||||
expect(Number.isNaN(result.totalDirectCost)).toBe(false)
|
||||
expect(Number.isNaN(result.totalIndirectCost)).toBe(false)
|
||||
|
||||
for (const act of result.perActivity) {
|
||||
expect(Number.isNaN(act.expectedDirectCost)).toBe(false)
|
||||
expect(Number.isNaN(act.expectedIndirectCost)).toBe(false)
|
||||
expect(Number.isNaN(act.expectedTotalCost)).toBe(false)
|
||||
expect(Number.isNaN(act.percentOfTotal)).toBe(false)
|
||||
expect(Number.isNaN(act.executionProbability)).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user