feat(sprint-6/etapa-2): panel de análisis de sensibilidad + doble línea en CumulativeSavingsChart
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
This commit is contained in:
@@ -4,10 +4,11 @@ import { formatCurrency } from '@/lib/format'
|
|||||||
import type { RoiResult } from '@/domain/roi'
|
import type { RoiResult } from '@/domain/roi'
|
||||||
|
|
||||||
interface CumulativeSavingsChartProps {
|
interface CumulativeSavingsChartProps {
|
||||||
roi: RoiResult
|
roi: RoiResult // caso ajustado (o base si no hay ajuste)
|
||||||
horizonYears: number
|
horizonYears: number
|
||||||
currency: string
|
currency: string
|
||||||
investment: number
|
investment: number // inversión ajustada (usada para cálculo de valores del eje Y)
|
||||||
|
roiBase?: RoiResult // caso base original — cuando presente, se muestra como línea secundaria
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CumulativeSavingsChart({
|
export function CumulativeSavingsChart({
|
||||||
@@ -15,6 +16,7 @@ export function CumulativeSavingsChart({
|
|||||||
horizonYears,
|
horizonYears,
|
||||||
currency,
|
currency,
|
||||||
investment,
|
investment,
|
||||||
|
roiBase,
|
||||||
}: CumulativeSavingsChartProps) {
|
}: CumulativeSavingsChartProps) {
|
||||||
const option = useMemo(() => {
|
const option = useMemo(() => {
|
||||||
const years = Array.from({ length: horizonYears + 1 }, (_, i) => i)
|
const years = Array.from({ length: horizonYears + 1 }, (_, i) => i)
|
||||||
@@ -45,17 +47,111 @@ export function CumulativeSavingsChart({
|
|||||||
lineStyle: { color: '#94a3b8', type: 'solid', width: 1 },
|
lineStyle: { color: '#94a3b8', type: 'solid', width: 1 },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Caso base (sliders de sensibilidad activos) — recupera la inversión original a partir
|
||||||
|
// de cumulativeSavingsHorizon (= annualSavings × horizonYears − investment), sin necesidad
|
||||||
|
// de una prop adicional: roiBase ya contiene toda la información necesaria.
|
||||||
|
let baseValues: number[] | null = null
|
||||||
|
if (roiBase) {
|
||||||
|
const investmentBase = roiBase.annualSavings * horizonYears - roiBase.cumulativeSavingsHorizon
|
||||||
|
baseValues = years.map((y) => roiBase.annualSavings * y - investmentBase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill-between: dos series stackeadas (lower invisible + diff coloreado) para sombrear
|
||||||
|
// exactamente el área entre la línea base y la ajustada, sin importar cuál es mayor.
|
||||||
|
const lowerValues = baseValues ? values.map((v, i) => Math.min(v, baseValues![i])) : null
|
||||||
|
const diffValues = baseValues ? values.map((v, i) => Math.abs(v - baseValues![i])) : null
|
||||||
|
|
||||||
|
const allValues = baseValues ? [...values, ...baseValues] : values
|
||||||
|
const yMin = Math.min(...allValues, 0)
|
||||||
|
const yMax = Math.max(...allValues, 0)
|
||||||
|
// Margen del 10% para que las líneas no queden pegadas al borde del gráfico
|
||||||
|
const yPad = Math.max((yMax - yMin) * 0.1, 1)
|
||||||
|
|
||||||
|
const series: Record<string, unknown>[] = []
|
||||||
|
|
||||||
|
if (lowerValues && diffValues) {
|
||||||
|
series.push({
|
||||||
|
name: 'fill-lower',
|
||||||
|
type: 'line',
|
||||||
|
data: years.map((y, i) => [y, lowerValues![i]]),
|
||||||
|
stack: 'fill-band',
|
||||||
|
symbol: 'none',
|
||||||
|
lineStyle: { opacity: 0 },
|
||||||
|
silent: true,
|
||||||
|
tooltip: { show: false },
|
||||||
|
})
|
||||||
|
series.push({
|
||||||
|
name: 'fill-diff',
|
||||||
|
type: 'line',
|
||||||
|
data: years.map((y, i) => [y, diffValues![i]]),
|
||||||
|
stack: 'fill-band',
|
||||||
|
symbol: 'none',
|
||||||
|
lineStyle: { opacity: 0 },
|
||||||
|
areaStyle: { color: '#F59845', opacity: 0.08 },
|
||||||
|
silent: true,
|
||||||
|
tooltip: { show: false },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseValues) {
|
||||||
|
series.push({
|
||||||
|
name: 'Base',
|
||||||
|
type: 'line',
|
||||||
|
data: years.map((y, i) => [y, baseValues![i]]),
|
||||||
|
smooth: false,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 5,
|
||||||
|
lineStyle: { color: '#94a3b8', width: 1.5, type: 'dashed' },
|
||||||
|
itemStyle: { color: '#94a3b8' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
series.push({
|
||||||
|
name: 'Ajustado',
|
||||||
|
type: 'line',
|
||||||
|
data: years.map((y, i) => [y, values[i]]),
|
||||||
|
smooth: false,
|
||||||
|
lineStyle: { color: '#F59845', width: 2.5 },
|
||||||
|
areaStyle: baseValues
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
color: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0, y: 0, x2: 0, y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: 'rgba(245,152,69,0.18)' },
|
||||||
|
{ offset: 1, color: 'rgba(245,152,69,0.02)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
itemStyle: { color: '#F59845' },
|
||||||
|
symbolSize: 6,
|
||||||
|
markLine: {
|
||||||
|
silent: true,
|
||||||
|
symbol: ['none', 'none'],
|
||||||
|
data: markLineData,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
formatter: (params: unknown[]) => {
|
formatter: (params: unknown[]) => {
|
||||||
const p = params[0] as { dataIndex: number; value: [number, number] }
|
const typedParams = params as Array<{ seriesName: string; value: [number, number] }>
|
||||||
const year = p.value[0]
|
const adjustedP = typedParams.find((p) => p.seriesName === 'Ajustado')
|
||||||
const val = p.value[1]
|
const baseP = typedParams.find((p) => p.seriesName === 'Base')
|
||||||
return `<div style="font-size:12px;line-height:1.6">
|
if (!adjustedP) return ''
|
||||||
|
const year = adjustedP.value[0]
|
||||||
|
const val = adjustedP.value[1]
|
||||||
|
let html = `<div style="font-size:12px;line-height:1.6">
|
||||||
<div style="font-weight:600;margin-bottom:4px">Año ${year}</div>
|
<div style="font-weight:600;margin-bottom:4px">Año ${year}</div>
|
||||||
<div>Acumulado neto: <strong style="color:${val >= 0 ? '#16A34A' : '#EF4444'}">${formatCurrency(val, currency)}</strong></div>
|
<div>Acumulado ajustado: <strong style="color:${val >= 0 ? '#16A34A' : '#EF4444'}">${formatCurrency(val, currency)}</strong></div>`
|
||||||
</div>`
|
if (baseP) {
|
||||||
|
const baseVal = baseP.value[1]
|
||||||
|
html += `<div>Acumulado base: <strong style="color:${baseVal >= 0 ? '#16A34A' : '#EF4444'}">${formatCurrency(baseVal, currency)}</strong></div>`
|
||||||
|
}
|
||||||
|
html += '</div>'
|
||||||
|
return html
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
grid: { left: 16, right: 24, top: 16, bottom: 8, containLabel: true },
|
grid: { left: 16, right: 24, top: 16, bottom: 8, containLabel: true },
|
||||||
@@ -72,6 +168,8 @@ export function CumulativeSavingsChart({
|
|||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
|
min: baseValues ? yMin - yPad : undefined,
|
||||||
|
max: baseValues ? yMax + yPad : undefined,
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
formatter: (v: number) => {
|
formatter: (v: number) => {
|
||||||
@@ -85,33 +183,9 @@ export function CumulativeSavingsChart({
|
|||||||
},
|
},
|
||||||
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
||||||
},
|
},
|
||||||
series: [
|
series,
|
||||||
{
|
|
||||||
type: 'line',
|
|
||||||
data: years.map((y, i) => [y, values[i]]),
|
|
||||||
smooth: false,
|
|
||||||
lineStyle: { color: '#F59845', width: 2.5 },
|
|
||||||
areaStyle: {
|
|
||||||
color: {
|
|
||||||
type: 'linear',
|
|
||||||
x: 0, y: 0, x2: 0, y2: 1,
|
|
||||||
colorStops: [
|
|
||||||
{ offset: 0, color: 'rgba(245,152,69,0.18)' },
|
|
||||||
{ offset: 1, color: 'rgba(245,152,69,0.02)' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
itemStyle: { color: '#F59845' },
|
|
||||||
symbolSize: 6,
|
|
||||||
markLine: {
|
|
||||||
silent: true,
|
|
||||||
symbol: ['none', 'none'],
|
|
||||||
data: markLineData,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
}, [roi, horizonYears, currency, investment])
|
}, [roi, horizonYears, currency, investment, roiBase])
|
||||||
|
|
||||||
return <ReactECharts option={option} style={{ height: 280 }} notMerge />
|
return <ReactECharts option={option} style={{ height: 280 }} notMerge />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { CostByResourceChart } from './CostByResourceChart'
|
|||||||
import { ActivitiesTable } from './ActivitiesTable'
|
import { ActivitiesTable } from './ActivitiesTable'
|
||||||
import { ComparisonTable } from './ComparisonTable'
|
import { ComparisonTable } from './ComparisonTable'
|
||||||
import { CumulativeSavingsChart } from './CumulativeSavingsChart'
|
import { CumulativeSavingsChart } from './CumulativeSavingsChart'
|
||||||
|
import { SensitivityPanel } from './SensitivityPanel'
|
||||||
import { TopSavingsChart } from './TopSavingsChart'
|
import { TopSavingsChart } from './TopSavingsChart'
|
||||||
import { TransparencySection } from './TransparencySection'
|
import { TransparencySection } from './TransparencySection'
|
||||||
import { AutomationScenarioNote } from './AutomationScenarioNote'
|
import { AutomationScenarioNote } from './AutomationScenarioNote'
|
||||||
@@ -67,6 +68,24 @@ export function ReportPage() {
|
|||||||
})
|
})
|
||||||
}, [simulation, process])
|
}, [simulation, process])
|
||||||
|
|
||||||
|
// Sliders del panel de sensibilidad — efímero, no persiste (Sprint 6 Etapa 2)
|
||||||
|
const [volumeAdjust, setVolumeAdjust] = useState(0) // entero: -50 a +100
|
||||||
|
const [investmentAdjust, setInvestmentAdjust] = useState(0) // entero: -50 a +100
|
||||||
|
|
||||||
|
// ROI ajustado por sliders de sensibilidad — recalcula solo los agregados anuales,
|
||||||
|
// sin tocar costPerExecutionActual ni costPerExecutionAutomated (son estables, no se re-simula).
|
||||||
|
const adjustedRoi = useMemo(() => {
|
||||||
|
if (!roi || !simulation?.result || !simulation?.resultAutomated || !process) return null
|
||||||
|
if (volumeAdjust === 0 && investmentAdjust === 0) return roi // sin cambios → mismo objeto
|
||||||
|
return calculateRoi({
|
||||||
|
costPerExecutionActual: simulation.result.totalCost,
|
||||||
|
costPerExecutionAutomated: simulation.resultAutomated.totalCost,
|
||||||
|
annualFrequency: process.annualFrequency * (1 + volumeAdjust / 100),
|
||||||
|
analysisHorizonYears: process.analysisHorizonYears,
|
||||||
|
automationInvestment: process.automationInvestment * (1 + investmentAdjust / 100),
|
||||||
|
})
|
||||||
|
}, [roi, simulation, process, volumeAdjust, investmentAdjust])
|
||||||
|
|
||||||
// Breakdown del escenario actual, indexado por activityId, para comparación lado a lado en tab Automatizado
|
// Breakdown del escenario actual, indexado por activityId, para comparación lado a lado en tab Automatizado
|
||||||
const actualBreakdownById = useMemo(() => {
|
const actualBreakdownById = useMemo(() => {
|
||||||
if (!simulation?.result) return undefined
|
if (!simulation?.result) return undefined
|
||||||
@@ -509,14 +528,24 @@ export function ReportPage() {
|
|||||||
<NeedsResimulate processId={processId} />
|
<NeedsResimulate processId={processId} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* KPIs de ROI */}
|
{/* KPIs de ROI — actualizados por sliders de sensibilidad */}
|
||||||
<RoiKpiBar
|
<RoiKpiBar
|
||||||
roi={roi}
|
roi={adjustedRoi ?? roi}
|
||||||
currency={process.currency}
|
currency={process.currency}
|
||||||
horizonYears={process.analysisHorizonYears}
|
horizonYears={process.analysisHorizonYears}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Tabla de comparación actividad por actividad */}
|
{/* Panel de sensibilidad — colapsado por defecto */}
|
||||||
|
<SensitivityPanel
|
||||||
|
process={process}
|
||||||
|
volumeAdjust={volumeAdjust}
|
||||||
|
investmentAdjust={investmentAdjust}
|
||||||
|
onVolumeChange={setVolumeAdjust}
|
||||||
|
onInvestmentChange={setInvestmentAdjust}
|
||||||
|
onReset={() => { setVolumeAdjust(0); setInvestmentAdjust(0) }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tabla de comparación actividad por actividad — per-ejecución, no varía con los sliders */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-base font-semibold text-slate-800 mb-4">
|
<h2 className="text-base font-semibold text-slate-800 mb-4">
|
||||||
Comparación por actividad
|
Comparación por actividad
|
||||||
@@ -536,10 +565,11 @@ export function ReportPage() {
|
|||||||
Ahorro acumulado neto ({process.analysisHorizonYears} años)
|
Ahorro acumulado neto ({process.analysisHorizonYears} años)
|
||||||
</h3>
|
</h3>
|
||||||
<CumulativeSavingsChart
|
<CumulativeSavingsChart
|
||||||
roi={roi}
|
roi={adjustedRoi ?? roi}
|
||||||
|
roiBase={volumeAdjust !== 0 || investmentAdjust !== 0 ? roi : undefined}
|
||||||
horizonYears={process.analysisHorizonYears}
|
horizonYears={process.analysisHorizonYears}
|
||||||
currency={process.currency}
|
currency={process.currency}
|
||||||
investment={process.automationInvestment}
|
investment={process.automationInvestment * (1 + investmentAdjust / 100)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-5">
|
||||||
|
|||||||
122
src/features/report/SensitivityPanel.tsx
Normal file
122
src/features/report/SensitivityPanel.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { ChevronDown, ChevronUp, SlidersHorizontal } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { formatCurrency, formatNumber } from '@/lib/format'
|
||||||
|
|
||||||
|
interface SensitivityPanelProps {
|
||||||
|
process: {
|
||||||
|
annualFrequency: number
|
||||||
|
automationInvestment: number
|
||||||
|
currency: string
|
||||||
|
}
|
||||||
|
volumeAdjust: number // entero -50 a +100
|
||||||
|
investmentAdjust: number // entero -50 a +100
|
||||||
|
onVolumeChange: (v: number) => void
|
||||||
|
onInvestmentChange: (v: number) => void
|
||||||
|
onReset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function deltaLabel(adjust: number): string {
|
||||||
|
if (adjust === 0) return '(valor base)'
|
||||||
|
return `(${adjust > 0 ? '+' : ''}${adjust}%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SensitivityPanel({
|
||||||
|
process,
|
||||||
|
volumeAdjust,
|
||||||
|
investmentAdjust,
|
||||||
|
onVolumeChange,
|
||||||
|
onInvestmentChange,
|
||||||
|
onReset,
|
||||||
|
}: SensitivityPanelProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const isActive = volumeAdjust !== 0 || investmentAdjust !== 0
|
||||||
|
|
||||||
|
const adjustedVolume = process.annualFrequency * (1 + volumeAdjust / 100)
|
||||||
|
const adjustedInvestment = process.automationInvestment * (1 + investmentAdjust / 100)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 mb-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((e) => !e)}
|
||||||
|
className="flex items-center justify-between w-full text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SlidersHorizontal className="h-4 w-4 text-amber-700" />
|
||||||
|
<span className="text-sm font-semibold text-amber-900">Análisis de sensibilidad</span>
|
||||||
|
{isActive && (
|
||||||
|
<span className="bg-orange-100 text-orange-700 text-xs font-medium px-2 py-0.5 rounded-full">
|
||||||
|
Análisis activo
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-amber-700" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-amber-700" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-4 space-y-5">
|
||||||
|
{/* Volumen anual */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-slate-700">Volumen anual</label>
|
||||||
|
<p className="text-sm font-mono text-slate-800 mt-0.5">
|
||||||
|
{formatNumber(adjustedVolume, 0)} ej/año {deltaLabel(volumeAdjust)}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={-50}
|
||||||
|
max={100}
|
||||||
|
step={10}
|
||||||
|
value={volumeAdjust}
|
||||||
|
onChange={(e) => onVolumeChange(Number(e.target.value))}
|
||||||
|
className="w-full mt-2"
|
||||||
|
style={{ accentColor: '#F59845' }}
|
||||||
|
aria-label="Ajuste de volumen anual"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-400 mt-1">
|
||||||
|
<span>-50%</span>
|
||||||
|
<span>0%</span>
|
||||||
|
<span>+100%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inversión en automatización */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-slate-700">Inversión en automatización</label>
|
||||||
|
<p className="text-sm font-mono text-slate-800 mt-0.5">
|
||||||
|
{formatCurrency(adjustedInvestment, process.currency)} {deltaLabel(investmentAdjust)}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={-50}
|
||||||
|
max={100}
|
||||||
|
step={10}
|
||||||
|
value={investmentAdjust}
|
||||||
|
onChange={(e) => onInvestmentChange(Number(e.target.value))}
|
||||||
|
className="w-full mt-2"
|
||||||
|
style={{ accentColor: '#F59845' }}
|
||||||
|
aria-label="Ajuste de inversión en automatización"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-400 mt-1">
|
||||||
|
<span>-50%</span>
|
||||||
|
<span>0%</span>
|
||||||
|
<span>+100%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isActive && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="outline" size="sm" onClick={onReset}>
|
||||||
|
Restablecer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
108
tests/features/report/sensitivity.test.ts
Normal file
108
tests/features/report/sensitivity.test.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Tests del recálculo de ROI ajustado por sliders de sensibilidad (Sprint 6, Etapa 2).
|
||||||
|
*
|
||||||
|
* La lógica vive inline en el useMemo `adjustedRoi` de ReportPage.tsx — no hay una función
|
||||||
|
* pura exportada (no se modifica domain/roi.ts por scope de la etapa). Estos tests replican
|
||||||
|
* exactamente esa transformación de inputs y verifican el comportamiento de calculateRoi()
|
||||||
|
* con los valores ajustados, igual que lo hace el componente.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { calculateRoi, type RoiInput } from '@/domain/roi'
|
||||||
|
|
||||||
|
const BASE: Omit<RoiInput, 'annualFrequency' | 'automationInvestment'> = {
|
||||||
|
costPerExecutionActual: 600,
|
||||||
|
costPerExecutionAutomated: 60,
|
||||||
|
analysisHorizonYears: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_FREQUENCY = 1200
|
||||||
|
const BASE_INVESTMENT = 50_000
|
||||||
|
|
||||||
|
function adjustedRoi(volumeAdjust: number, investmentAdjust: number) {
|
||||||
|
return calculateRoi({
|
||||||
|
...BASE,
|
||||||
|
annualFrequency: BASE_FREQUENCY * (1 + volumeAdjust / 100),
|
||||||
|
automationInvestment: BASE_INVESTMENT * (1 + investmentAdjust / 100),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Sensibilidad — ajuste de volumen anual', () => {
|
||||||
|
it('volumen +20%: annualFrequency y annualSavings escalan 1.2x respecto al base', () => {
|
||||||
|
const base = adjustedRoi(0, 0)
|
||||||
|
const adjusted = adjustedRoi(20, 0)
|
||||||
|
expect(adjusted.annualSavings).toBeCloseTo(base.annualSavings * 1.2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('volumen -30%: annualSavings escala 0.7x, payback se alarga', () => {
|
||||||
|
const base = adjustedRoi(0, 0)
|
||||||
|
const adjusted = adjustedRoi(-30, 0)
|
||||||
|
expect(adjusted.annualSavings).toBeCloseTo(base.annualSavings * 0.7)
|
||||||
|
expect(adjusted.paybackMonths).toBeGreaterThan(base.paybackMonths)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Sensibilidad — ajuste de inversión en automatización', () => {
|
||||||
|
it('inversión +50%: payback se alarga 1.5x, ROI% baja proporcionalmente', () => {
|
||||||
|
const base = adjustedRoi(0, 0)
|
||||||
|
const adjusted = adjustedRoi(0, 50)
|
||||||
|
expect(adjusted.paybackMonths).toBeCloseTo(base.paybackMonths * 1.5)
|
||||||
|
expect(adjusted.roiAnnualPercent).toBeCloseTo(base.roiAnnualPercent / 1.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inversión -50%: payback se acorta a la mitad, ROI% se duplica', () => {
|
||||||
|
const base = adjustedRoi(0, 0)
|
||||||
|
const adjusted = adjustedRoi(0, -50)
|
||||||
|
expect(adjusted.paybackMonths).toBeCloseTo(base.paybackMonths * 0.5)
|
||||||
|
expect(adjusted.roiAnnualPercent).toBeCloseTo(base.roiAnnualPercent * 2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Sensibilidad — ajuste combinado', () => {
|
||||||
|
it('volumen +20% e inversión +50% simultáneos: ambos efectos se componen', () => {
|
||||||
|
const base = adjustedRoi(0, 0)
|
||||||
|
const adjusted = adjustedRoi(20, 50)
|
||||||
|
// annualSavings depende solo de volumen
|
||||||
|
expect(adjusted.annualSavings).toBeCloseTo(base.annualSavings * 1.2)
|
||||||
|
// payback = inversión / annualSavings → (1.5 * inv) / (1.2 * savings) = base * 1.25
|
||||||
|
expect(adjusted.paybackMonths).toBeCloseTo(base.paybackMonths * (1.5 / 1.2))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('volumen -30% e inversión -50% simultáneos', () => {
|
||||||
|
const base = adjustedRoi(0, 0)
|
||||||
|
const adjusted = adjustedRoi(-30, -50)
|
||||||
|
expect(adjusted.annualSavings).toBeCloseTo(base.annualSavings * 0.7)
|
||||||
|
expect(adjusted.paybackMonths).toBeCloseTo(base.paybackMonths * (0.5 / 0.7))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Sensibilidad — reset a 0', () => {
|
||||||
|
it('volumeAdjust=0 e investmentAdjust=0 reproduce exactamente el ROI base', () => {
|
||||||
|
const base = calculateRoi({
|
||||||
|
...BASE,
|
||||||
|
annualFrequency: BASE_FREQUENCY,
|
||||||
|
automationInvestment: BASE_INVESTMENT,
|
||||||
|
})
|
||||||
|
const reset = adjustedRoi(0, 0)
|
||||||
|
expect(reset).toEqual(base)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('después de mover sliders, volver a 0/0 coincide con el valor base original', () => {
|
||||||
|
adjustedRoi(40, -20) // simula haber movido los sliders
|
||||||
|
const resetAgain = adjustedRoi(0, 0)
|
||||||
|
const base = adjustedRoi(0, 0)
|
||||||
|
expect(resetAgain).toEqual(base)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Sensibilidad — límites del rango (-50 a +100)', () => {
|
||||||
|
it('volumen en el límite inferior (-50%) no produce frecuencia negativa', () => {
|
||||||
|
const adjusted = adjustedRoi(-50, 0)
|
||||||
|
expect(adjusted.annualSavings).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('volumen en el límite superior (+100%) duplica el ahorro anual', () => {
|
||||||
|
const base = adjustedRoi(0, 0)
|
||||||
|
const adjusted = adjustedRoi(100, 0)
|
||||||
|
expect(adjusted.annualSavings).toBeCloseTo(base.annualSavings * 2)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user