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:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
32
tests/conftest.py
Normal file
32
tests/conftest.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Pytest fixtures for Palladium tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _env(monkeypatch):
|
||||
"""Default test env vars so TEIClient() doesn't need a real .env."""
|
||||
monkeypatch.setenv("ATHENA_BASE_URL", "https://athena.test")
|
||||
monkeypatch.setenv("ATHENA_API_KEY", "test-key")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def amazon_connect_seed():
|
||||
"""Load the Amazon Connect study's seed data."""
|
||||
sys.path.insert(0, str(ROOT / "studies" / "202602_AmazonConnect"))
|
||||
try:
|
||||
import seed_data # type: ignore[import-not-found]
|
||||
return seed_data
|
||||
finally:
|
||||
# Leave the path alone — many tests will use the seed
|
||||
pass
|
||||
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)
|
||||
174
tests/test_client.py
Normal file
174
tests/test_client.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
TEI client tests with mocked HTTP.
|
||||
|
||||
We mock ``requests.Session.request`` so tests do not require network access
|
||||
or a live Athena instance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.tei_client import AthenaAPIError, TEIClient
|
||||
|
||||
|
||||
def _mock_response(status: int, body=None) -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.status_code = status
|
||||
resp.content = b"{}" if body is None else json.dumps(body).encode()
|
||||
resp.json.return_value = body if body is not None else {}
|
||||
resp.text = json.dumps(body or {})
|
||||
return resp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch) -> TEIClient:
|
||||
c = TEIClient()
|
||||
c.session = MagicMock()
|
||||
return c
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_requires_base_url(self, monkeypatch):
|
||||
monkeypatch.delenv("ATHENA_BASE_URL", raising=False)
|
||||
with pytest.raises(ValueError, match="ATHENA_BASE_URL"):
|
||||
TEIClient(api_key="x")
|
||||
|
||||
def test_requires_api_key(self, monkeypatch):
|
||||
monkeypatch.delenv("ATHENA_API_KEY", raising=False)
|
||||
with pytest.raises(ValueError, match="ATHENA_API_KEY"):
|
||||
TEIClient(base_url="https://example.com")
|
||||
|
||||
def test_authorization_header(self):
|
||||
c = TEIClient(base_url="https://example.com", api_key="abc123")
|
||||
assert c.session.headers["Authorization"] == "Api-Key abc123"
|
||||
|
||||
|
||||
class TestPaths:
|
||||
"""Verify each endpoint targets the documented URL."""
|
||||
|
||||
def _last_call_url(self, client: TEIClient) -> str:
|
||||
return client.session.request.call_args.kwargs["url"]
|
||||
|
||||
def test_list_reports_path(self, client):
|
||||
client.session.request.return_value = _mock_response(
|
||||
200, {"results": [], "next": None}
|
||||
)
|
||||
client.list_reports()
|
||||
assert self._last_call_url(client) == "https://athena.test/api/v1/tei/reports/"
|
||||
|
||||
def test_get_tool_path(self, client):
|
||||
client.session.request.return_value = _mock_response(200, {"id": "abc"})
|
||||
client.get_tool("abc123")
|
||||
assert self._last_call_url(client).endswith("/api/v1/tei/tools/abc123/")
|
||||
|
||||
def test_calculate_path(self, client):
|
||||
client.session.request.return_value = _mock_response(200, {})
|
||||
client.calculate("abc")
|
||||
assert self._last_call_url(client).endswith("/api/v1/tei/tools/abc/calculate/")
|
||||
assert client.session.request.call_args.kwargs["method"] == "POST"
|
||||
|
||||
def test_export_path(self, client):
|
||||
client.session.request.return_value = _mock_response(200, {})
|
||||
client.export("abc")
|
||||
assert self._last_call_url(client).endswith("/api/v1/tei/tools/abc/export/")
|
||||
|
||||
def test_aggregate_summary_path(self, client):
|
||||
client.session.request.return_value = _mock_response(200, {})
|
||||
client.aggregate_summary()
|
||||
assert self._last_call_url(client).endswith("/api/v1/tei/summary/")
|
||||
|
||||
def test_save_version_path(self, client):
|
||||
client.session.request.return_value = _mock_response(201, {"version_number": 1})
|
||||
client.save_version("abc", note="initial")
|
||||
url = self._last_call_url(client)
|
||||
assert url.endswith("/api/v1/tei/tools/abc/versions/")
|
||||
body = client.session.request.call_args.kwargs["json"]
|
||||
assert body == {"note": "initial"}
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
def test_404_raises_athena_error(self, client):
|
||||
client.session.request.return_value = _mock_response(
|
||||
404, {"detail": "Not found"}
|
||||
)
|
||||
with pytest.raises(AthenaAPIError) as ei:
|
||||
client.get_tool("missing")
|
||||
assert ei.value.status_code == 404
|
||||
assert "Not found" in ei.value.detail
|
||||
|
||||
def test_test_connection_returns_error_dict(self, client):
|
||||
client.session.request.return_value = _mock_response(
|
||||
401, {"detail": "Invalid token"}
|
||||
)
|
||||
result = client.test_connection()
|
||||
assert result["status"] == "error"
|
||||
assert result["authenticated"] is False
|
||||
assert result["error_code"] == 401
|
||||
|
||||
|
||||
class TestPagination:
|
||||
def test_walks_next_links(self, client):
|
||||
# First page returns one item with a `next` URL; second page returns
|
||||
# one more item and no next.
|
||||
page1 = _mock_response(
|
||||
200,
|
||||
{
|
||||
"results": [{"id": 1}],
|
||||
"next": "https://athena.test/api/v1/tei/reports/?page=2",
|
||||
},
|
||||
)
|
||||
page2 = _mock_response(200, {"results": [{"id": 2}], "next": None})
|
||||
client.session.request.return_value = page1
|
||||
client.session.get.return_value = page2 # follow next via session.get
|
||||
|
||||
out = client.list_reports()
|
||||
assert [r["id"] for r in out] == [1, 2]
|
||||
|
||||
|
||||
class TestNormalizeValue:
|
||||
def test_year_underscore_keys(self):
|
||||
out = TEIClient._normalize_value(
|
||||
{"field_key": "x", "year_1": 100, "year_2": 200, "risk_adjustment": 0.1}
|
||||
)
|
||||
assert out["year_values"] == {"1": 100.0, "2": 200.0}
|
||||
assert out["risk_adjustment"] == 0.1
|
||||
|
||||
def test_year_values_dict_passthrough(self):
|
||||
out = TEIClient._normalize_value(
|
||||
{
|
||||
"field_key": "x",
|
||||
"year_values": {"1": 50, "3": 75},
|
||||
"notes": " hi ",
|
||||
}
|
||||
)
|
||||
assert out["year_values"] == {"1": 50.0, "3": 75.0}
|
||||
assert out["notes"] == " hi "
|
||||
|
||||
def test_initial_carried(self):
|
||||
out = TEIClient._normalize_value(
|
||||
{"field_key": "x", "initial": 1000, "year_1": 5}
|
||||
)
|
||||
assert out["initial"] == 1000.0
|
||||
|
||||
def test_scalar_value(self):
|
||||
out = TEIClient._normalize_value({"field_key": "rate", "value": 0.10})
|
||||
assert out["value"] == 0.10
|
||||
assert "year_values" not in out
|
||||
|
||||
|
||||
class TestUpdateValuesPayload:
|
||||
def test_wraps_in_envelope(self, client):
|
||||
client.session.request.return_value = _mock_response(200, {})
|
||||
client.update_values(
|
||||
"abc",
|
||||
[{"field_key": "x", "year_1": 100}, {"field_key": "y", "year_1": 200}],
|
||||
)
|
||||
body = client.session.request.call_args.kwargs["json"]
|
||||
assert "values" in body
|
||||
assert len(body["values"]) == 2
|
||||
assert body["values"][0]["field_key"] == "x"
|
||||
assert body["values"][0]["year_values"] == {"1": 100.0}
|
||||
98
tests/test_export.py
Normal file
98
tests/test_export.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Tests for core.export.report_data — envelope shape and computed totals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.export import build_report_data
|
||||
from core.export.report_data import _compute_summary, _yearly_totals
|
||||
|
||||
|
||||
class TestComputeSummary:
|
||||
def test_amazon_connect_totals(self, amazon_connect_seed):
|
||||
s = _compute_summary(
|
||||
amazon_connect_seed.BENEFITS,
|
||||
amazon_connect_seed.COSTS,
|
||||
0.10,
|
||||
3,
|
||||
)
|
||||
assert s["total_benefits_pv"] == pytest.approx(101_696_791, abs=1500)
|
||||
assert s["total_costs_pv"] == pytest.approx(22_983_076, abs=1500)
|
||||
assert s["npv"] == pytest.approx(78_713_715, abs=2000)
|
||||
assert s["roi_pct"] == pytest.approx(342, abs=1)
|
||||
assert s["payback_months"] is not None and s["payback_months"] < 6
|
||||
|
||||
def test_yearly_breakdown_three_rows(self, amazon_connect_seed):
|
||||
s = _compute_summary(
|
||||
amazon_connect_seed.BENEFITS, amazon_connect_seed.COSTS, 0.10, 3
|
||||
)
|
||||
assert len(s["yearly_breakdown"]) == 3
|
||||
assert [r["year"] for r in s["yearly_breakdown"]] == [1, 2, 3]
|
||||
|
||||
|
||||
class TestYearlyTotals:
|
||||
def test_only_within_horizon(self):
|
||||
items = [
|
||||
{"year_values": {"1": 100, "2": 200, "3": 300, "4": 999}},
|
||||
]
|
||||
assert _yearly_totals(items, 3) == [100.0, 200.0, 300.0]
|
||||
|
||||
def test_skips_invalid_keys(self):
|
||||
items = [{"year_values": {"1": 50, "abc": 999}}]
|
||||
assert _yearly_totals(items, 2) == [50.0, 0.0]
|
||||
|
||||
|
||||
class TestBuildReportData:
|
||||
def _stub_client(self, seed):
|
||||
c = MagicMock()
|
||||
c.get_tool_with_data.return_value = {
|
||||
"tool": {"id": "pid", "name": "T", "report": "rid", "proposal": 7},
|
||||
"fields": [],
|
||||
"values": seed.BENEFITS + seed.COSTS,
|
||||
}
|
||||
c.get_report.return_value = {
|
||||
"id": "rid",
|
||||
"name": "Amazon Connect",
|
||||
"vendor": "AWS",
|
||||
"version": "1.0",
|
||||
"discount_rate": "0.10",
|
||||
"analysis_period_years": 3,
|
||||
}
|
||||
c.export.return_value = {"echoed": True}
|
||||
return c
|
||||
|
||||
def test_envelope_shape(self, amazon_connect_seed):
|
||||
client = self._stub_client(amazon_connect_seed)
|
||||
env = build_report_data(client, "pid", study_slug="202602_AmazonConnect")
|
||||
assert set(env) >= {
|
||||
"metadata",
|
||||
"report",
|
||||
"fields",
|
||||
"values",
|
||||
"summary",
|
||||
"athena_export",
|
||||
"scenarios",
|
||||
}
|
||||
assert env["metadata"]["study_slug"] == "202602_AmazonConnect"
|
||||
assert env["metadata"]["proposal"] == 7
|
||||
assert env["values"]["benefits"]
|
||||
assert env["values"]["costs"]
|
||||
|
||||
def test_scenarios_have_three_keys(self, amazon_connect_seed):
|
||||
client = self._stub_client(amazon_connect_seed)
|
||||
env = build_report_data(client, "pid")
|
||||
assert set(env["scenarios"]) == {"conservative", "moderate", "aggressive"}
|
||||
|
||||
def test_no_scenarios_flag(self, amazon_connect_seed):
|
||||
client = self._stub_client(amazon_connect_seed)
|
||||
env = build_report_data(client, "pid", include_scenarios=False)
|
||||
assert "scenarios" not in env
|
||||
|
||||
def test_local_summary_matches_seed(self, amazon_connect_seed):
|
||||
client = self._stub_client(amazon_connect_seed)
|
||||
env = build_report_data(client, "pid", include_scenarios=False)
|
||||
assert env["summary"]["total_benefits_pv"] == pytest.approx(
|
||||
101_696_791, abs=1500
|
||||
)
|
||||
Reference in New Issue
Block a user