refactor: restructure repo into core/app modules with per-study folders
Reorganize Palladium codebase into a modular architecture with `core/` shared logic and `app/` Streamlit UI, separating per-study assets into `studies/YYYYMM_<Vendor>/` folders containing notebooks, seed data, and configuration. Update README to reflect new structure, add `.gitignore` entries for `.env` and study exports, and refresh component documentation.
This commit is contained in:
206
tests/test_calculations.py
Normal file
206
tests/test_calculations.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
Tests for core.calculations — reproduces the Forrester Amazon Connect TEI
|
||||
totals from the published study within rounding.
|
||||
|
||||
The PDF reports (Cash Flow Analysis, p.25):
|
||||
Benefits PV (RA) = $101,696,791
|
||||
Costs PV (RA) = $ 22,983,076
|
||||
NPV = $ 78,713,715
|
||||
ROI = 342%
|
||||
Payback = <6 months
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core.calculations import (
|
||||
SCENARIOS,
|
||||
apply_scenario,
|
||||
discount_factor,
|
||||
npv,
|
||||
payback_months,
|
||||
payback_years,
|
||||
present_value,
|
||||
present_value_series,
|
||||
risk_adjust_benefit,
|
||||
risk_adjust_cost,
|
||||
roi,
|
||||
roi_percentage,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Building blocks
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDiscounting:
|
||||
def test_discount_factor_year_zero(self):
|
||||
assert discount_factor(0, 0.10) == pytest.approx(1.0)
|
||||
|
||||
def test_discount_factor_known_value(self):
|
||||
# 1/(1.10)^3
|
||||
assert discount_factor(3, 0.10) == pytest.approx(0.7513148, rel=1e-5)
|
||||
|
||||
def test_negative_year_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
discount_factor(-1, 0.10)
|
||||
|
||||
def test_present_value_year_one(self):
|
||||
assert present_value(110, 1, 0.10) == pytest.approx(100.0)
|
||||
|
||||
def test_present_value_series_three_years(self):
|
||||
# 100 each year for 3 years at 10% → ≈ 248.685
|
||||
assert present_value_series([100, 100, 100], 0.10) == pytest.approx(
|
||||
248.685, rel=1e-3
|
||||
)
|
||||
|
||||
|
||||
class TestNPV:
|
||||
def test_zero_initial(self):
|
||||
assert npv([100, 100], 0.0) == pytest.approx(200.0)
|
||||
|
||||
def test_with_initial(self):
|
||||
# 1000 invested up-front, 600 returned each of 2 years at 10%
|
||||
result = npv([600, 600], 0.10, initial=-1000)
|
||||
# PV of returns ≈ 545.45 + 495.87 = 1041.32, NPV ≈ 41.32
|
||||
assert result == pytest.approx(41.32, abs=0.5)
|
||||
|
||||
|
||||
class TestRiskAdjustment:
|
||||
def test_benefit_zero_risk(self):
|
||||
assert risk_adjust_benefit(100, 0.0) == 100
|
||||
|
||||
def test_benefit_15pct(self):
|
||||
assert risk_adjust_benefit(100, 0.15) == pytest.approx(85.0)
|
||||
|
||||
def test_cost_5pct_upward(self):
|
||||
assert risk_adjust_cost(100, 0.05) == pytest.approx(105.0)
|
||||
|
||||
def test_clamping(self):
|
||||
assert risk_adjust_benefit(100, 1.5) == 0 # clamped to 1.0
|
||||
assert risk_adjust_benefit(100, -0.5) == 100 # clamped to 0
|
||||
|
||||
|
||||
class TestROI:
|
||||
def test_zero_costs_returns_zero(self):
|
||||
assert roi(100, 0) == 0.0
|
||||
|
||||
def test_known(self):
|
||||
assert roi_percentage(101_696_791, 22_983_076) == pytest.approx(342, abs=1)
|
||||
|
||||
|
||||
class TestPayback:
|
||||
def test_immediate(self):
|
||||
assert payback_years(0, [100]) == 0.0
|
||||
|
||||
def test_amazon_connect_under_six_months(self):
|
||||
# Initial $1.196M, Y1 net ~$20M → quick crossing
|
||||
years = payback_years(1_196_250, [19_997_953, 31_562_489, 47_443_905])
|
||||
assert years is not None
|
||||
assert payback_months(1_196_250, [19_997_953, 31_562_489, 47_443_905]) < 6
|
||||
|
||||
def test_never_recovered(self):
|
||||
assert payback_years(1000, [100, 100, 100]) is None
|
||||
|
||||
|
||||
class TestScenarios:
|
||||
def test_default_scenarios_present(self):
|
||||
assert set(SCENARIOS) == {"conservative", "moderate", "aggressive"}
|
||||
|
||||
def test_moderate_is_passthrough(self):
|
||||
items = [
|
||||
{
|
||||
"table": "benefits",
|
||||
"field_key": "x",
|
||||
"year_values": {"1": 1000, "2": 2000},
|
||||
"risk_adjustment": 0.15,
|
||||
}
|
||||
]
|
||||
out = apply_scenario(items, "moderate")
|
||||
assert out[0]["year_values"] == {"1": 1000.0, "2": 2000.0}
|
||||
assert out[0]["risk_adjustment"] == pytest.approx(0.15)
|
||||
|
||||
def test_conservative_lowers_benefits(self):
|
||||
items = [
|
||||
{
|
||||
"table": "benefits",
|
||||
"field_key": "x",
|
||||
"year_values": {"1": 1000},
|
||||
"risk_adjustment": 0.15,
|
||||
}
|
||||
]
|
||||
out = apply_scenario(items, "conservative")
|
||||
assert out[0]["year_values"]["1"] == pytest.approx(800.0)
|
||||
# 0.15 + 0.10 = 0.25
|
||||
assert out[0]["risk_adjustment"] == pytest.approx(0.25)
|
||||
|
||||
def test_aggressive_increases_benefits(self):
|
||||
items = [
|
||||
{
|
||||
"table": "benefits",
|
||||
"field_key": "x",
|
||||
"year_values": {"1": 1000},
|
||||
"risk_adjustment": 0.15,
|
||||
}
|
||||
]
|
||||
out = apply_scenario(items, "aggressive")
|
||||
assert out[0]["year_values"]["1"] == pytest.approx(1150.0)
|
||||
assert out[0]["risk_adjustment"] == pytest.approx(0.10)
|
||||
|
||||
def test_unknown_scenario_raises(self):
|
||||
with pytest.raises(KeyError):
|
||||
apply_scenario([], "purple")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# End-to-end: reproduce the PDF totals
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAmazonConnectComposite:
|
||||
"""Reproduce the Forrester Amazon Connect TEI numbers within rounding."""
|
||||
|
||||
DISCOUNT_RATE = 0.10
|
||||
EXPECTED_BENEFITS_PV = 101_696_791
|
||||
EXPECTED_COSTS_PV = 22_983_076
|
||||
EXPECTED_NPV = 78_713_715
|
||||
EXPECTED_ROI = 342 # percent
|
||||
TOLERANCE = 1_500 # dollars; PDF rounding at thousands
|
||||
|
||||
def _benefits_pv(self, seed) -> float:
|
||||
total = 0.0
|
||||
for b in seed.BENEFITS:
|
||||
rf = b["risk_adjustment"]
|
||||
yr = [b["year_values"][str(y)] for y in (1, 2, 3)]
|
||||
yr_ra = [risk_adjust_benefit(v, rf) for v in yr]
|
||||
total += npv(yr_ra, self.DISCOUNT_RATE)
|
||||
return total
|
||||
|
||||
def _costs_pv(self, seed) -> float:
|
||||
total = 0.0
|
||||
for c in seed.COSTS:
|
||||
rf = c["risk_adjustment"]
|
||||
init = risk_adjust_cost(c.get("initial") or 0, rf)
|
||||
yr = [c["year_values"][str(y)] for y in (1, 2, 3)]
|
||||
yr_ra = [risk_adjust_cost(v, rf) for v in yr]
|
||||
total += npv(yr_ra, self.DISCOUNT_RATE, initial=init)
|
||||
return total
|
||||
|
||||
def test_benefits_pv(self, amazon_connect_seed):
|
||||
result = self._benefits_pv(amazon_connect_seed)
|
||||
assert result == pytest.approx(self.EXPECTED_BENEFITS_PV, abs=self.TOLERANCE)
|
||||
|
||||
def test_costs_pv(self, amazon_connect_seed):
|
||||
result = self._costs_pv(amazon_connect_seed)
|
||||
assert result == pytest.approx(self.EXPECTED_COSTS_PV, abs=self.TOLERANCE)
|
||||
|
||||
def test_npv(self, amazon_connect_seed):
|
||||
b = self._benefits_pv(amazon_connect_seed)
|
||||
c = self._costs_pv(amazon_connect_seed)
|
||||
assert (b - c) == pytest.approx(self.EXPECTED_NPV, abs=self.TOLERANCE)
|
||||
|
||||
def test_roi(self, amazon_connect_seed):
|
||||
b = self._benefits_pv(amazon_connect_seed)
|
||||
c = self._costs_pv(amazon_connect_seed)
|
||||
assert roi_percentage(b, c) == pytest.approx(self.EXPECTED_ROI, abs=1)
|
||||
Reference in New Issue
Block a user