"""Business case maths + exports.""" from __future__ import annotations import pytest from tokencalc.business_case import build_business_case, npv, payback_years from tokencalc.defaults import ( CTM_DEFAULT_FEATURE_SCOPES, CTM_DEFAULT_SITES, CTM_DEFAULT_TAKEOUTS, DEFAULT_METERS, DEFAULT_PRICING, ) from tokencalc.exports import ( export_excel, scenario_state_from_json, scenario_state_to_json, ) def test_npv_hand_check(): """100/yr for 3 years @ 8%: 92.593 + 85.734 + 79.383 = 257.710.""" assert npv([100, 100, 100], 0.08) == pytest.approx(257.710, abs=0.001) def test_payback_interpolation(): # -100 in Y1, +200 in Y2 → breakeven halfway through Y2 = 1.5 years assert payback_years([-100, 200, 0]) == pytest.approx(1.5) assert payback_years([-100, -100, -100]) is None assert payback_years([50, 50, 50]) == pytest.approx(0.0) def _case(scenario="realistic", **kw): return build_business_case( CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, DEFAULT_METERS, DEFAULT_PRICING, CTM_DEFAULT_TAKEOUTS, scenario, **kw, ) def test_business_case_shape(): case = _case() assert set(case) == { "cost_by_year", "benefit_by_year", "takeouts_by_year", "net_by_year", "cumulative_net", "npv", "payback_period_years", "roi_3yr", } for key in ("cost_by_year", "benefit_by_year", "net_by_year"): assert {"Y1", "Y2", "Y3"} <= set(case[key].columns) def test_net_consistency(): """NET row must equal benefits + takeouts − costs, per year.""" case = _case() nb = case["net_by_year"].set_index("line") for y in ("Y1", "Y2", "Y3"): assert nb.loc["NET", y] == pytest.approx( nb.loc["TOTAL BENEFITS", y] + nb.loc["TOTAL TAKEOUTS", y] - nb.loc["TOTAL COSTS", y] ) # cumulative is a running sum of NET assert nb.loc["Cumulative net", "Y3"] == pytest.approx( sum(nb.loc["NET", y] for y in ("Y1", "Y2", "Y3")) ) def test_npv_matches_net_rows(): case = _case() nb = case["net_by_year"].set_index("line") net = [nb.loc["NET", y] for y in ("Y1", "Y2", "Y3")] assert case["npv"] == pytest.approx(npv(net, 0.08)) def test_three_scenarios_distinct(): npvs = {s: _case(s)["npv"] for s in ("floor", "realistic", "stretch")} assert len({round(v) for v in npvs.values()}) == 3 assert npvs["floor"] < npvs["realistic"] < npvs["stretch"] def test_implementation_amortization(): base = _case() with_impl = _case(implementation_cost=900_000) nb, nb2 = ( c["net_by_year"].set_index("line") for c in (base, with_impl) ) for y in ("Y1", "Y2", "Y3"): assert nb2.loc["TOTAL COSTS", y] == pytest.approx( nb.loc["TOTAL COSTS", y] + 300_000 ) def test_excel_export_readable(tmp_path): case = _case() path = export_excel( { "Business Case": case["net_by_year"], "Costs": case["cost_by_year"], "Benefits": case["benefit_by_year"], }, tmp_path / "ctm.xlsx", ) import openpyxl wb = openpyxl.load_workbook(path) assert set(wb.sheetnames) == {"Business Case", "Costs", "Benefits"} def test_scenario_json_roundtrip(tmp_path): p = tmp_path / "state.json" scenario_state_to_json( CTM_DEFAULT_SITES, CTM_DEFAULT_TAKEOUTS, CTM_DEFAULT_FEATURE_SCOPES, p ) sites, takeouts, scopes, _rollout = scenario_state_from_json(p) assert [s.site_name for s in sites] == [s.site_name for s in CTM_DEFAULT_SITES] assert takeouts[0].annual_cost == CTM_DEFAULT_TAKEOUTS[0].annual_cost assert scopes[0].adoption_curve == CTM_DEFAULT_FEATURE_SCOPES[0].adoption_curve