Move planner financial model from client-side JS to server-side Python (calculator.py + /planner/calculate endpoint). Add full test coverage: 227 calculator tests and 371 billing tests covering SQL helpers, webhooks, routes, and subscription gating with Hypothesis fuzzing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1066 lines
41 KiB
Python
1066 lines
41 KiB
Python
"""
|
|
Tests for the padel court financial calculator.
|
|
|
|
Verifies the Python port matches the original JS calc() behavior
|
|
across all four venue/ownership combinations and edge cases.
|
|
"""
|
|
import json
|
|
import math
|
|
|
|
import pytest
|
|
from hypothesis import given, settings
|
|
from hypothesis import strategies as st
|
|
|
|
from padelnomics.planner.calculator import (
|
|
DEFAULTS,
|
|
_round,
|
|
calc,
|
|
calc_irr,
|
|
pmt,
|
|
validate_state,
|
|
)
|
|
|
|
# ── Helper ─────────────────────────────────────────────────
|
|
|
|
def default_state(**overrides):
|
|
"""Return a validated default state with optional overrides."""
|
|
return validate_state(overrides)
|
|
|
|
|
|
def approx(val, rel=1e-6, abs=1e-9):
|
|
return pytest.approx(val, rel=rel, abs=abs)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# _round — JS-compatible half-up rounding
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestRound:
|
|
def test_half_rounds_up(self):
|
|
assert _round(0.5) == 1
|
|
assert _round(1.5) == 2
|
|
assert _round(2.5) == 3
|
|
|
|
def test_below_half_rounds_down(self):
|
|
assert _round(0.4) == 0
|
|
assert _round(1.4) == 1
|
|
|
|
def test_above_half_rounds_up(self):
|
|
assert _round(0.6) == 1
|
|
assert _round(1.7) == 2
|
|
|
|
def test_negative(self):
|
|
assert _round(-0.5) == 0 # JS: Math.round(-0.5) = 0
|
|
assert _round(-1.5) == -1 # JS: Math.round(-1.5) = -1
|
|
|
|
def test_integers_unchanged(self):
|
|
assert _round(0) == 0
|
|
assert _round(5) == 5
|
|
assert _round(-3) == -3
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# pmt — loan payment
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestPmt:
|
|
def test_zero_rate(self):
|
|
assert pmt(0, 120, 100000) == approx(100000 / 120)
|
|
|
|
def test_standard_loan(self):
|
|
# 5% annual = 0.004167/mo, 10yr = 120 months, 100K loan
|
|
monthly = pmt(0.05 / 12, 120, 100000)
|
|
assert monthly == approx(1060.66, rel=1e-4)
|
|
|
|
def test_high_rate(self):
|
|
monthly = pmt(0.10 / 12, 60, 50000)
|
|
assert monthly > 0
|
|
# Total paid should exceed principal
|
|
assert monthly * 60 > 50000
|
|
|
|
def test_single_period(self):
|
|
# 1 period, 5% rate, 10000 principal
|
|
monthly = pmt(0.05, 1, 10000)
|
|
assert monthly == approx(10500.0)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# calc_irr — Newton-Raphson IRR solver
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestCalcIRR:
|
|
def test_simple_doubling(self):
|
|
# Invest 100, get 200 back in 1 year = 100% IRR
|
|
irr = calc_irr([-100, 200])
|
|
assert irr == approx(1.0, rel=1e-6)
|
|
|
|
def test_even_cashflows(self):
|
|
# Invest 1000, get 400/yr for 3 years
|
|
irr = calc_irr([-1000, 400, 400, 400])
|
|
assert irr == approx(0.09701, rel=1e-3)
|
|
|
|
def test_negative_project(self):
|
|
# Project that loses money
|
|
irr = calc_irr([-1000, 100, 100, 100])
|
|
assert irr < 0
|
|
|
|
def test_breakeven(self):
|
|
# Invest 1000, get exactly 1000 back next year = 0% IRR
|
|
irr = calc_irr([-1000, 1000])
|
|
assert abs(irr) < 1e-6
|
|
|
|
def test_clamping(self):
|
|
# Very bad project — IRR should be clamped, not crash
|
|
irr = calc_irr([-1000, 1, 1, 1, 1])
|
|
assert irr >= -0.99
|
|
assert math.isfinite(irr)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# validate_state
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestValidateState:
|
|
def test_empty_input_returns_defaults(self):
|
|
s = validate_state({})
|
|
assert s == DEFAULTS
|
|
|
|
def test_overrides_applied(self):
|
|
s = validate_state({"dblCourts": 8, "venue": "outdoor"})
|
|
assert s["dblCourts"] == 8
|
|
assert s["venue"] == "outdoor"
|
|
# Everything else stays default
|
|
assert s["sglCourts"] == DEFAULTS["sglCourts"]
|
|
|
|
def test_type_coercion_string_to_int(self):
|
|
s = validate_state({"dblCourts": "6"})
|
|
assert s["dblCourts"] == 6
|
|
assert isinstance(s["dblCourts"], int)
|
|
|
|
def test_type_coercion_string_to_float(self):
|
|
s = validate_state({"ballCost": "2.5"})
|
|
assert s["ballCost"] == 2.5
|
|
assert isinstance(s["ballCost"], float)
|
|
|
|
def test_invalid_value_keeps_default(self):
|
|
s = validate_state({"dblCourts": "not_a_number"})
|
|
assert s["dblCourts"] == DEFAULTS["dblCourts"]
|
|
|
|
def test_list_coercion(self):
|
|
s = validate_state({"ramp": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]})
|
|
assert s["ramp"] == [1.0] * 12
|
|
|
|
def test_non_list_for_list_field_keeps_default(self):
|
|
s = validate_state({"ramp": "invalid"})
|
|
assert s["ramp"] == DEFAULTS["ramp"]
|
|
|
|
def test_unknown_keys_ignored(self):
|
|
s = validate_state({"unknown_key": 42, "dblCourts": 3})
|
|
assert "unknown_key" not in s
|
|
assert s["dblCourts"] == 3
|
|
|
|
def test_returns_new_dict(self):
|
|
s = validate_state({})
|
|
s["dblCourts"] = 999
|
|
assert DEFAULTS["dblCourts"] != 999 # Original not mutated
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# calc — full model, default scenario (indoor + rent)
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestCalcDefaultScenario:
|
|
@pytest.fixture()
|
|
def d(self):
|
|
return calc(default_state())
|
|
|
|
def test_total_courts(self, d):
|
|
assert d["totalCourts"] == 6
|
|
|
|
def test_sqm_is_hall(self, d):
|
|
# Indoor venue → sqm is hallSqm
|
|
expected = 4 * 330 + 2 * 220 + 200 + 6 * 20
|
|
assert d["hallSqm"] == expected
|
|
assert d["sqm"] == expected
|
|
|
|
def test_capex_positive(self, d):
|
|
assert d["capex"] > 0
|
|
|
|
def test_capex_items_sum(self, d):
|
|
items_sum = sum(i["amount"] for i in d["capexItems"])
|
|
assert items_sum == d["capex"]
|
|
|
|
def test_capex_per_court(self, d):
|
|
assert d["capexPerCourt"] == approx(d["capex"] / 6)
|
|
|
|
def test_capex_per_sqm(self, d):
|
|
assert d["capexPerSqm"] == approx(d["capex"] / d["sqm"])
|
|
|
|
def test_opex_items_sum(self, d):
|
|
items_sum = sum(i["amount"] for i in d["opexItems"])
|
|
assert items_sum == d["opex"]
|
|
|
|
def test_annual_opex(self, d):
|
|
assert d["annualOpex"] == d["opex"] * 12
|
|
|
|
def test_equity_plus_loan_equals_capex(self, d):
|
|
assert d["equity"] + d["loanAmount"] == d["capex"]
|
|
|
|
def test_ltv(self, d):
|
|
assert d["ltv"] == approx(d["loanAmount"] / d["capex"])
|
|
|
|
def test_monthly_payment_positive(self, d):
|
|
assert d["monthlyPayment"] > 0
|
|
|
|
def test_annual_debt_service(self, d):
|
|
assert d["annualDebtService"] == approx(d["monthlyPayment"] * 12)
|
|
|
|
def test_weighted_rate(self, d):
|
|
# 4 dbl courts, 2 sgl courts
|
|
# peakPct=40, ratePeak=50, rateOffPeak=35, rateSingle=30
|
|
dbl_rate = 50 * 0.4 + 35 * 0.6 # 41
|
|
expected = (4 * dbl_rate + 2 * 30) / 6
|
|
assert d["weightedRate"] == approx(expected)
|
|
|
|
def test_avail_hours_month(self, d):
|
|
assert d["availHoursMonth"] == 16 * 29 * 6 # hoursPerDay * daysPerMonth * courts
|
|
|
|
def test_booked_hours_month(self, d):
|
|
assert d["bookedHoursMonth"] == approx(d["availHoursMonth"] * 0.4)
|
|
|
|
def test_revenue_components(self, d):
|
|
assert d["courtRevMonth"] == approx(d["bookedHoursMonth"] * d["weightedRate"])
|
|
assert d["feeDeduction"] == approx(d["courtRevMonth"] * 0.1)
|
|
assert d["membershipRev"] == 6 * 500
|
|
assert d["fbRev"] == 6 * 300
|
|
assert d["coachingRev"] == 6 * 200
|
|
assert d["retailRev"] == 6 * 80
|
|
|
|
def test_gross_minus_fees_equals_net(self, d):
|
|
assert d["netRevMonth"] == approx(d["grossRevMonth"] - d["feeDeduction"])
|
|
|
|
def test_ebitda_month(self, d):
|
|
assert d["ebitdaMonth"] == approx(d["netRevMonth"] - d["opex"])
|
|
|
|
def test_net_cf_month(self, d):
|
|
assert d["netCFMonth"] == approx(d["ebitdaMonth"] - d["monthlyPayment"])
|
|
|
|
def test_months_count(self, d):
|
|
assert len(d["months"]) == 60
|
|
|
|
def test_months_year_assignment(self, d):
|
|
assert d["months"][0]["yr"] == 1
|
|
assert d["months"][11]["yr"] == 1
|
|
assert d["months"][12]["yr"] == 2
|
|
assert d["months"][59]["yr"] == 5
|
|
|
|
def test_months_cm_range(self, d):
|
|
cms = [m["cm"] for m in d["months"]]
|
|
assert min(cms) == 1
|
|
assert max(cms) == 12
|
|
|
|
def test_ramp_applied_first_12_months(self, d):
|
|
ramp = DEFAULTS["ramp"]
|
|
for i in range(12):
|
|
assert d["months"][i]["ramp"] == ramp[i]
|
|
# Month 13+ should be 1.0
|
|
assert d["months"][12]["ramp"] == 1.0
|
|
assert d["months"][59]["ramp"] == 1.0
|
|
|
|
def test_indoor_no_seasonality(self, d):
|
|
# Indoor venues have seas=1 for all months
|
|
for m in d["months"]:
|
|
assert m["seas"] == 1
|
|
|
|
def test_cumulative_starts_negative(self, d):
|
|
assert d["months"][0]["cum"] < 0
|
|
|
|
def test_cumulative_is_running_sum(self, d):
|
|
# cum[0] = -capex + ncf[0]
|
|
assert d["months"][0]["cum"] == approx(-d["capex"] + d["months"][0]["ncf"])
|
|
# cum[i] = cum[i-1] + ncf[i]
|
|
for i in range(1, 60):
|
|
assert d["months"][i]["cum"] == approx(
|
|
d["months"][i - 1]["cum"] + d["months"][i]["ncf"]
|
|
)
|
|
|
|
def test_annuals_count(self, d):
|
|
assert len(d["annuals"]) == 5
|
|
|
|
def test_annuals_revenue_is_sum_of_months(self, d):
|
|
for annual in d["annuals"]:
|
|
y = annual["year"]
|
|
month_rev = sum(m["totalRev"] for m in d["months"] if m["yr"] == y)
|
|
assert annual["revenue"] == approx(month_rev)
|
|
|
|
def test_stab_ebitda_is_year3(self, d):
|
|
assert d["stabEbitda"] == d["annuals"][2]["ebitda"]
|
|
|
|
def test_exit_value(self, d):
|
|
assert d["exitValue"] == approx(d["stabEbitda"] * 6)
|
|
|
|
def test_irr_finite(self, d):
|
|
assert math.isfinite(d["irr"])
|
|
|
|
def test_moic_positive(self, d):
|
|
assert d["moic"] > 0
|
|
|
|
def test_dscr_count(self, d):
|
|
assert len(d["dscr"]) == 5
|
|
|
|
def test_payback_idx_valid(self, d):
|
|
idx = d["paybackIdx"]
|
|
if idx >= 0:
|
|
assert d["months"][idx]["cum"] >= 0
|
|
if idx > 0:
|
|
assert d["months"][idx - 1]["cum"] < 0
|
|
|
|
def test_break_even_util_in_range(self, d):
|
|
assert 0 < d["breakEvenUtil"] < 2 # Should be a fraction, could exceed 1
|
|
|
|
def test_ebitda_margin(self, d):
|
|
assert d["ebitdaMargin"] == approx(d["ebitdaMonth"] / d["netRevMonth"])
|
|
|
|
def test_opex_ratio(self, d):
|
|
assert d["opexRatio"] == approx(d["opex"] / d["netRevMonth"])
|
|
|
|
def test_rent_ratio_for_rental(self, d):
|
|
# Default is rent, so there should be a Rent item
|
|
rent_item = next(i for i in d["opexItems"] if i["name"] == "Rent")
|
|
assert d["rentRatio"] == approx(rent_item["amount"] / d["netRevMonth"])
|
|
|
|
def test_yield_on_cost(self, d):
|
|
assert d["yieldOnCost"] == approx(d["stabEbitda"] / d["capex"])
|
|
|
|
def test_avg_util_year3(self, d):
|
|
y3 = d["annuals"][2]
|
|
assert d["avgUtil"] == approx(y3["booked"] / y3["avail"])
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# calc — indoor + buy scenario
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestCalcIndoorBuy:
|
|
@pytest.fixture()
|
|
def d(self):
|
|
return calc(default_state(venue="indoor", own="buy"))
|
|
|
|
def test_capex_includes_hall_construction(self, d):
|
|
names = [i["name"] for i in d["capexItems"]]
|
|
assert "Hall Construction" in names
|
|
assert "Foundation" in names
|
|
assert "Land Purchase" in names
|
|
assert "HVAC System" in names
|
|
|
|
def test_no_rent_in_opex(self, d):
|
|
names = [i["name"] for i in d["opexItems"]]
|
|
assert "Rent" not in names
|
|
assert "Property Tax" in names
|
|
|
|
def test_capex_higher_than_rent_scenario(self, d):
|
|
d_rent = calc(default_state(venue="indoor", own="rent"))
|
|
assert d["capex"] > d_rent["capex"]
|
|
|
|
def test_miscellaneous_is_8000_for_buy(self, d):
|
|
misc = next(i for i in d["capexItems"] if i["name"] == "Miscellaneous")
|
|
assert misc["amount"] == 8000
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# calc — outdoor + rent scenario
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestCalcOutdoorRent:
|
|
@pytest.fixture()
|
|
def d(self):
|
|
return calc(default_state(venue="outdoor", own="rent"))
|
|
|
|
def test_sqm_is_outdoor_land(self, d):
|
|
expected = 4 * 300 + 2 * 200 + 100
|
|
assert d["outdoorLandSqm"] == expected
|
|
assert d["sqm"] == expected
|
|
|
|
def test_capex_items_outdoor_specific(self, d):
|
|
names = [i["name"] for i in d["capexItems"]]
|
|
assert "Concrete Foundation" in names
|
|
assert "Site Work" in names
|
|
assert "Lighting" in names
|
|
assert "Fencing" in names
|
|
# Should NOT have indoor items
|
|
assert "Hall Construction" not in names
|
|
assert "Floor Preparation" not in names
|
|
|
|
def test_no_land_purchase_for_rent(self, d):
|
|
names = [i["name"] for i in d["capexItems"]]
|
|
assert "Land Purchase" not in names
|
|
|
|
def test_seasonality_applied(self, d):
|
|
season = DEFAULTS["season"]
|
|
for m in d["months"][:12]:
|
|
cm = (m["m"] - 1) % 12
|
|
assert m["seas"] == season[cm]
|
|
|
|
def test_zero_season_months_have_zero_avail(self, d):
|
|
for m in d["months"][:12]:
|
|
if m["seas"] == 0:
|
|
assert m["avail"] == 0
|
|
assert m["booked"] == 0
|
|
assert m["courtRev"] == 0
|
|
|
|
def test_days_per_month_outdoor(self, d):
|
|
assert d["daysPerMonth"] == 25
|
|
|
|
def test_miscellaneous_is_6000_for_rent(self, d):
|
|
misc = next(i for i in d["capexItems"] if i["name"] == "Miscellaneous")
|
|
assert misc["amount"] == 6000
|
|
|
|
def test_no_heating_cleaning_water_in_opex(self, d):
|
|
names = [i["name"] for i in d["opexItems"]]
|
|
assert "Heating" not in names
|
|
assert "Cleaning" not in names
|
|
assert "Water" not in names
|
|
|
|
def test_outdoor_rent_in_opex(self, d):
|
|
rent = next(i for i in d["opexItems"] if i["name"] == "Rent")
|
|
assert rent["amount"] == 400
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# calc — outdoor + buy scenario
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestCalcOutdoorBuy:
|
|
@pytest.fixture()
|
|
def d(self):
|
|
return calc(default_state(venue="outdoor", own="buy"))
|
|
|
|
def test_land_purchase_included(self, d):
|
|
names = [i["name"] for i in d["capexItems"]]
|
|
assert "Land Purchase" in names
|
|
assert "Transaction Costs" in names
|
|
|
|
def test_property_tax_in_opex(self, d):
|
|
names = [i["name"] for i in d["opexItems"]]
|
|
assert "Property Tax" in names
|
|
assert "Rent" not in names
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# calc — indoor + rent (explicit, verifying capex items)
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestCalcIndoorRent:
|
|
@pytest.fixture()
|
|
def d(self):
|
|
return calc(default_state(venue="indoor", own="rent"))
|
|
|
|
def test_fit_out_items(self, d):
|
|
names = [i["name"] for i in d["capexItems"]]
|
|
assert "Floor Preparation" in names
|
|
assert "HVAC Upgrade" in names
|
|
assert "Lighting Upgrade" in names
|
|
assert "Fit-Out & Reception" in names
|
|
# Should NOT have buy items
|
|
assert "Hall Construction" not in names
|
|
assert "Land Purchase" not in names
|
|
|
|
def test_rent_in_opex(self, d):
|
|
rent = next(i for i in d["opexItems"] if i["name"] == "Rent")
|
|
expected = d["hallSqm"] * DEFAULTS["rentSqm"]
|
|
assert rent["amount"] == _round(expected)
|
|
|
|
def test_indoor_opex_has_heating_and_water(self, d):
|
|
names = [i["name"] for i in d["opexItems"]]
|
|
assert "Heating" in names
|
|
assert "Water" in names
|
|
assert "Cleaning" in names
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# calc — edge cases
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestCalcEdgeCases:
|
|
def test_zero_courts(self):
|
|
d = calc(default_state(dblCourts=0, sglCourts=0))
|
|
assert d["totalCourts"] == 0
|
|
assert d["hallSqm"] == 0
|
|
assert d["sqm"] == 0
|
|
assert d["availHoursMonth"] == 0
|
|
assert d["bookedHoursMonth"] == 0
|
|
assert d["courtRevMonth"] == 0
|
|
assert d["membershipRev"] == 0
|
|
assert len(d["months"]) == 60
|
|
assert len(d["annuals"]) == 5
|
|
|
|
def test_zero_loan(self):
|
|
d = calc(default_state(loanPct=0))
|
|
assert d["loanAmount"] == 0
|
|
assert d["monthlyPayment"] == 0
|
|
assert d["annualDebtService"] == 0
|
|
assert d["ltv"] == 0
|
|
|
|
def test_full_loan(self):
|
|
d = calc(default_state(loanPct=100))
|
|
assert d["equity"] == 0
|
|
assert d["loanAmount"] == d["capex"]
|
|
|
|
def test_zero_contingency(self):
|
|
d = calc(default_state(contingencyPct=0))
|
|
# No contingency item
|
|
names = [i["name"] for i in d["capexItems"]]
|
|
assert not any("Contingency" in n for n in names)
|
|
# capex = sum of items (no extra)
|
|
items_sum = sum(i["amount"] for i in d["capexItems"])
|
|
assert items_sum == d["capex"]
|
|
|
|
def test_zero_utilization(self):
|
|
d = calc(default_state(utilTarget=0))
|
|
assert d["bookedHoursMonth"] == 0
|
|
assert d["courtRevMonth"] == 0
|
|
# breakEvenUtil is breakEvenHrs / availHoursMonth; availHoursMonth > 0
|
|
assert d["breakEvenUtil"] > 0
|
|
|
|
def test_full_utilization(self):
|
|
d = calc(default_state(utilTarget=100))
|
|
assert d["bookedHoursMonth"] == approx(d["availHoursMonth"])
|
|
|
|
def test_zero_interest_rate(self):
|
|
d = calc(default_state(interestRate=0))
|
|
# pmt with 0 rate = simple division
|
|
expected = d["loanAmount"] / (max(DEFAULTS["loanTerm"], 1) * 12)
|
|
assert d["monthlyPayment"] == approx(expected)
|
|
|
|
def test_staff_included_when_positive(self):
|
|
d = calc(default_state(staff=5000))
|
|
names = [i["name"] for i in d["opexItems"]]
|
|
assert "Staff" in names
|
|
staff = next(i for i in d["opexItems"] if i["name"] == "Staff")
|
|
assert staff["amount"] == 5000
|
|
|
|
def test_staff_excluded_when_zero(self):
|
|
d = calc(default_state(staff=0))
|
|
names = [i["name"] for i in d["opexItems"]]
|
|
assert "Staff" not in names
|
|
|
|
def test_single_court_only(self):
|
|
d = calc(default_state(dblCourts=0, sglCourts=1))
|
|
assert d["totalCourts"] == 1
|
|
assert d["weightedRate"] == 30 # single court rate
|
|
|
|
def test_double_court_only(self):
|
|
d = calc(default_state(dblCourts=1, sglCourts=0))
|
|
assert d["totalCourts"] == 1
|
|
# Weighted rate = peak*peakPct + offPeak*(1-peakPct) for all dbl
|
|
expected = 50 * 0.4 + 35 * 0.6
|
|
assert d["weightedRate"] == approx(expected)
|
|
|
|
def test_hold_years_beyond_annuals(self):
|
|
# holdYears=10 but only 5 years of annuals
|
|
d = calc(default_state(holdYears=10))
|
|
assert math.isfinite(d["irr"])
|
|
assert d["moic"] > 0
|
|
|
|
def test_loan_term_minimum_one(self):
|
|
# loanTerm=0 should be clamped to 1 via max()
|
|
d = calc(default_state(loanTerm=0))
|
|
assert d["monthlyPayment"] > 0
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# calc — financing math
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestCalcFinancing:
|
|
def test_equity_calculation(self):
|
|
d = calc(default_state(loanPct=85))
|
|
expected_equity = _round(d["capex"] * (1 - 85 / 100))
|
|
assert d["equity"] == expected_equity
|
|
|
|
def test_remaining_loan_decreases_with_hold_years(self):
|
|
d1 = calc(default_state(holdYears=1))
|
|
d5 = calc(default_state(holdYears=5))
|
|
assert d5["remainingLoan"] < d1["remainingLoan"]
|
|
|
|
def test_remaining_loan_zero_when_fully_repaid(self):
|
|
# holdYears >= loanTerm * 1.5 → remaining = 0
|
|
d = calc(default_state(holdYears=20, loanTerm=10))
|
|
assert d["remainingLoan"] == approx(0, abs=1)
|
|
|
|
def test_net_exit(self):
|
|
d = calc(default_state())
|
|
assert d["netExit"] == approx(d["exitValue"] - d["remainingLoan"])
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# calc — revenue model details
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestCalcRevenue:
|
|
def test_racket_revenue(self):
|
|
d = calc(default_state())
|
|
expected = d["bookedHoursMonth"] * (15 / 100) * 2 * 5
|
|
assert d["racketRev"] == approx(expected)
|
|
|
|
def test_ball_margin(self):
|
|
d = calc(default_state())
|
|
expected = d["bookedHoursMonth"] * (10 / 100) * (3 - 1.5)
|
|
assert d["ballMargin"] == approx(expected)
|
|
|
|
def test_gross_rev_is_sum_of_components(self):
|
|
d = calc(default_state())
|
|
expected = (
|
|
d["courtRevMonth"]
|
|
+ d["racketRev"]
|
|
+ d["ballMargin"]
|
|
+ d["membershipRev"]
|
|
+ d["fbRev"]
|
|
+ d["coachingRev"]
|
|
+ d["retailRev"]
|
|
)
|
|
assert d["grossRevMonth"] == approx(expected)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# calc — monthly projection details
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestCalcMonthly:
|
|
def test_month_fields_present(self):
|
|
d = calc(default_state())
|
|
m = d["months"][0]
|
|
expected_keys = {
|
|
"m", "cm", "yr", "ramp", "seas", "effUtil", "avail", "booked",
|
|
"courtRev", "fees", "ancillary", "membership", "totalRev",
|
|
"opex", "loan", "ebitda", "ncf", "cum",
|
|
}
|
|
assert set(m.keys()) == expected_keys
|
|
|
|
def test_month_ebitda_is_rev_plus_opex(self):
|
|
d = calc(default_state())
|
|
for m in d["months"]:
|
|
assert m["ebitda"] == approx(m["totalRev"] + m["opex"]) # opex is negative
|
|
|
|
def test_month_ncf_is_ebitda_plus_loan(self):
|
|
d = calc(default_state())
|
|
for m in d["months"]:
|
|
assert m["ncf"] == approx(m["ebitda"] + m["loan"])
|
|
|
|
def test_fees_are_negative(self):
|
|
d = calc(default_state())
|
|
for m in d["months"]:
|
|
if m["courtRev"] > 0:
|
|
assert m["fees"] < 0
|
|
|
|
def test_month_ramp_progression(self):
|
|
d = calc(default_state())
|
|
# Ramp should generally increase over first 12 months
|
|
ramps = [m["ramp"] for m in d["months"][:12]]
|
|
assert ramps == sorted(ramps)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# calc — annual summary integrity
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestCalcAnnuals:
|
|
def test_annual_ebitda_is_sum_of_months(self):
|
|
d = calc(default_state())
|
|
for annual in d["annuals"]:
|
|
y = annual["year"]
|
|
expected = sum(m["ebitda"] for m in d["months"] if m["yr"] == y)
|
|
assert annual["ebitda"] == approx(expected)
|
|
|
|
def test_annual_ncf_is_sum_of_months(self):
|
|
d = calc(default_state())
|
|
for annual in d["annuals"]:
|
|
y = annual["year"]
|
|
expected = sum(m["ncf"] for m in d["months"] if m["yr"] == y)
|
|
assert annual["ncf"] == approx(expected)
|
|
|
|
def test_annual_ds_is_sum_of_abs_loan(self):
|
|
d = calc(default_state())
|
|
for annual in d["annuals"]:
|
|
y = annual["year"]
|
|
expected = sum(abs(m["loan"]) for m in d["months"] if m["yr"] == y)
|
|
assert annual["ds"] == approx(expected)
|
|
|
|
def test_year3_revenue_higher_than_year1(self):
|
|
d = calc(default_state())
|
|
assert d["annuals"][2]["revenue"] > d["annuals"][0]["revenue"]
|
|
|
|
def test_dscr_calculation(self):
|
|
d = calc(default_state())
|
|
for dscr_entry in d["dscr"]:
|
|
annual = d["annuals"][dscr_entry["year"] - 1]
|
|
if annual["ds"] > 0:
|
|
assert dscr_entry["dscr"] == approx(annual["ebitda"] / annual["ds"])
|
|
else:
|
|
assert dscr_entry["dscr"] == 999
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# calc — efficiency metrics
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestCalcEfficiency:
|
|
@pytest.fixture()
|
|
def d(self):
|
|
return calc(default_state())
|
|
|
|
def test_rev_pah(self, d):
|
|
assert d["revPAH"] == approx(d["netRevMonth"] / d["availHoursMonth"])
|
|
|
|
def test_rev_per_sqm(self, d):
|
|
assert d["revPerSqm"] == approx((d["netRevMonth"] * 12) / d["sqm"])
|
|
|
|
def test_cost_per_booked_hr(self, d):
|
|
expected = (d["opex"] + d["monthlyPayment"]) / d["bookedHoursMonth"]
|
|
assert d["costPerBookedHr"] == approx(expected)
|
|
|
|
def test_cash_on_cash(self, d):
|
|
expected = d["annuals"][2]["ncf"] / d["equity"]
|
|
assert d["cashOnCash"] == approx(expected)
|
|
|
|
def test_debt_yield(self, d):
|
|
expected = d["stabEbitda"] / d["loanAmount"]
|
|
assert d["debtYield"] == approx(expected)
|
|
|
|
def test_break_even_hrs(self, d):
|
|
w_rate = d["weightedRate"]
|
|
rev_per_hr = (
|
|
w_rate * (1 - 10 / 100)
|
|
+ (15 / 100) * 2 * 5
|
|
+ (10 / 100) * (3 - 1.5)
|
|
)
|
|
fixed = d["opex"] + d["monthlyPayment"]
|
|
assert d["breakEvenHrs"] == approx(fixed / rev_per_hr)
|
|
|
|
def test_break_even_hrs_per_court(self, d):
|
|
expected = d["breakEvenHrs"] / 6 / 29
|
|
assert d["breakEvenHrsPerCourt"] == approx(expected)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# calc — known-value regression (default scenario)
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestCalcRegression:
|
|
"""
|
|
Pin specific numeric outputs for the default scenario to catch
|
|
unintended changes. Values computed from the current Python implementation
|
|
which was verified against the original JS version.
|
|
"""
|
|
|
|
@pytest.fixture()
|
|
def d(self):
|
|
return calc(default_state())
|
|
|
|
def test_capex_value(self, d):
|
|
assert d["capex"] == 270380
|
|
|
|
def test_total_courts(self, d):
|
|
assert d["totalCourts"] == 6
|
|
|
|
def test_hall_sqm(self, d):
|
|
# 4*330 + 2*220 + 200 + 6*20 = 2080
|
|
assert d["hallSqm"] == 2080
|
|
|
|
def test_opex_value(self, d):
|
|
# Rent + Insurance + Electricity + Heating + Water + Maintenance +
|
|
# Cleaning + Marketing (no staff at 0)
|
|
assert d["opex"] > 0
|
|
|
|
def test_payback_idx(self, d):
|
|
assert d["paybackIdx"] == 13
|
|
|
|
def test_irr_in_range(self, d):
|
|
# Default scenario should have very high IRR (rent scenario, low capex)
|
|
assert d["irr"] > 1.0
|
|
|
|
def test_moic_in_range(self, d):
|
|
assert d["moic"] > 10
|
|
|
|
def test_months_60(self, d):
|
|
assert len(d["months"]) == 60
|
|
|
|
def test_annuals_5(self, d):
|
|
assert len(d["annuals"]) == 5
|
|
|
|
def test_dscr_5_entries(self, d):
|
|
assert len(d["dscr"]) == 5
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# calc — contingency
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestCalcContingency:
|
|
def test_contingency_amount(self):
|
|
d = calc(default_state(contingencyPct=10))
|
|
cont_item = next(i for i in d["capexItems"] if "Contingency" in i["name"])
|
|
sub = sum(i["amount"] for i in d["capexItems"] if "Contingency" not in i["name"])
|
|
assert cont_item["amount"] == _round(sub * 10 / 100)
|
|
|
|
def test_higher_contingency_increases_capex(self):
|
|
d10 = calc(default_state(contingencyPct=10))
|
|
d20 = calc(default_state(contingencyPct=20))
|
|
assert d20["capex"] > d10["capex"]
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# calc — JSON serializable output
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
class TestCalcJsonSerializable:
|
|
def test_output_is_json_serializable(self):
|
|
import json
|
|
d = calc(default_state())
|
|
# Should not raise
|
|
serialized = json.dumps(d)
|
|
assert len(serialized) > 0
|
|
|
|
def test_no_nan_or_inf_in_defaults(self):
|
|
d = calc(default_state())
|
|
self._check_no_nan_inf(d)
|
|
|
|
def _check_no_nan_inf(self, obj, path=""):
|
|
if isinstance(obj, float):
|
|
assert math.isfinite(obj), f"Non-finite float at {path}: {obj}"
|
|
elif isinstance(obj, dict):
|
|
for k, v in obj.items():
|
|
self._check_no_nan_inf(v, f"{path}.{k}")
|
|
elif isinstance(obj, list):
|
|
for i, v in enumerate(obj):
|
|
self._check_no_nan_inf(v, f"{path}[{i}]")
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Parameterized: all 4 venue/ownership combos
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
ALL_COMBOS = [
|
|
("indoor", "rent"),
|
|
("indoor", "buy"),
|
|
("outdoor", "rent"),
|
|
("outdoor", "buy"),
|
|
]
|
|
|
|
|
|
class TestAllCombosParameterized:
|
|
"""Structural invariants that must hold for every venue/ownership combo."""
|
|
|
|
@pytest.fixture(params=ALL_COMBOS, ids=lambda c: f"{c[0]}-{c[1]}")
|
|
def d(self, request):
|
|
venue, own = request.param
|
|
return calc(default_state(venue=venue, own=own))
|
|
|
|
def test_capex_positive(self, d):
|
|
assert d["capex"] > 0
|
|
|
|
def test_capex_items_sum_to_capex(self, d):
|
|
assert sum(i["amount"] for i in d["capexItems"]) == d["capex"]
|
|
|
|
def test_opex_items_sum_to_opex(self, d):
|
|
assert sum(i["amount"] for i in d["opexItems"]) == d["opex"]
|
|
|
|
def test_equity_plus_loan_equals_capex(self, d):
|
|
assert d["equity"] + d["loanAmount"] == d["capex"]
|
|
|
|
def test_60_months(self, d):
|
|
assert len(d["months"]) == 60
|
|
|
|
def test_5_annuals(self, d):
|
|
assert len(d["annuals"]) == 5
|
|
|
|
def test_5_dscr_entries(self, d):
|
|
assert len(d["dscr"]) == 5
|
|
|
|
def test_cumulative_running_sum(self, d):
|
|
assert d["months"][0]["cum"] == approx(-d["capex"] + d["months"][0]["ncf"])
|
|
for i in range(1, 60):
|
|
assert d["months"][i]["cum"] == approx(
|
|
d["months"][i - 1]["cum"] + d["months"][i]["ncf"]
|
|
)
|
|
|
|
def test_annual_revenue_sums_match_months(self, d):
|
|
for annual in d["annuals"]:
|
|
y = annual["year"]
|
|
expected = sum(m["totalRev"] for m in d["months"] if m["yr"] == y)
|
|
assert annual["revenue"] == approx(expected)
|
|
|
|
def test_annual_ncf_sums_match_months(self, d):
|
|
for annual in d["annuals"]:
|
|
y = annual["year"]
|
|
expected = sum(m["ncf"] for m in d["months"] if m["yr"] == y)
|
|
assert annual["ncf"] == approx(expected)
|
|
|
|
def test_json_serializable(self, d):
|
|
json.dumps(d) # should not raise
|
|
|
|
def test_no_nan_or_inf(self, d):
|
|
_assert_finite(d)
|
|
|
|
def test_irr_finite(self, d):
|
|
assert math.isfinite(d["irr"])
|
|
|
|
def test_moic_non_negative(self, d):
|
|
assert d["moic"] >= 0 or d["capex"] == 0
|
|
|
|
def test_total_courts(self, d):
|
|
assert d["totalCourts"] == 6
|
|
|
|
def test_sqm_positive(self, d):
|
|
assert d["sqm"] > 0
|
|
|
|
|
|
def _assert_finite(obj, path=""):
|
|
if isinstance(obj, float):
|
|
assert math.isfinite(obj), f"Non-finite at {path}: {obj}"
|
|
elif isinstance(obj, dict):
|
|
for k, v in obj.items():
|
|
_assert_finite(v, f"{path}.{k}")
|
|
elif isinstance(obj, list):
|
|
for i, v in enumerate(obj):
|
|
_assert_finite(v, f"{path}[{i}]")
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Parameterized: utilization sweep
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
UTILIZATIONS = [0, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
|
|
|
|
|
|
@pytest.mark.parametrize("util", UTILIZATIONS)
|
|
def test_utilization_sweep_no_crash(util):
|
|
d = calc(default_state(utilTarget=util))
|
|
assert d["bookedHoursMonth"] == approx(d["availHoursMonth"] * util / 100)
|
|
_assert_finite(d)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Parameterized: court count sweep
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
COURT_CONFIGS = [(0, 0), (1, 0), (0, 1), (4, 2), (10, 5), (30, 0), (0, 30)]
|
|
|
|
|
|
@pytest.mark.parametrize("dbl,sgl", COURT_CONFIGS, ids=lambda x: f"{x}c" if isinstance(x, int) else None)
|
|
def test_court_count_sweep(dbl, sgl):
|
|
d = calc(default_state(dblCourts=dbl, sglCourts=sgl))
|
|
assert d["totalCourts"] == dbl + sgl
|
|
assert len(d["months"]) == 60
|
|
_assert_finite(d)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# Hypothesis: property-based fuzz testing
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
# Strategy for a plausible state dict
|
|
plausible_state = st.fixed_dictionaries({
|
|
"venue": st.sampled_from(["indoor", "outdoor"]),
|
|
"own": st.sampled_from(["rent", "buy"]),
|
|
"dblCourts": st.integers(0, 30),
|
|
"sglCourts": st.integers(0, 30),
|
|
"ratePeak": st.integers(0, 150),
|
|
"rateOffPeak": st.integers(0, 150),
|
|
"rateSingle": st.integers(0, 150),
|
|
"peakPct": st.integers(0, 100),
|
|
"hoursPerDay": st.integers(0, 24),
|
|
"bookingFee": st.integers(0, 30),
|
|
"utilTarget": st.integers(0, 100),
|
|
"courtCostDbl": st.integers(0, 80000),
|
|
"courtCostSgl": st.integers(0, 60000),
|
|
"loanPct": st.integers(0, 100),
|
|
"interestRate": st.floats(0, 15, allow_nan=False, allow_infinity=False),
|
|
"loanTerm": st.integers(0, 30),
|
|
"holdYears": st.integers(1, 20),
|
|
"exitMultiple": st.floats(0, 20, allow_nan=False, allow_infinity=False),
|
|
"contingencyPct": st.integers(0, 30),
|
|
"staff": st.integers(0, 20000),
|
|
})
|
|
|
|
|
|
class TestHypothesisFuzz:
|
|
@given(raw_state=plausible_state)
|
|
@settings(max_examples=200, deadline=2000)
|
|
def test_never_crashes(self, raw_state):
|
|
s = validate_state(raw_state)
|
|
d = calc(s)
|
|
assert isinstance(d, dict)
|
|
|
|
@given(raw_state=plausible_state)
|
|
@settings(max_examples=200, deadline=2000)
|
|
def test_always_60_months(self, raw_state):
|
|
d = calc(validate_state(raw_state))
|
|
assert len(d["months"]) == 60
|
|
|
|
@given(raw_state=plausible_state)
|
|
@settings(max_examples=200, deadline=2000)
|
|
def test_always_5_annuals(self, raw_state):
|
|
d = calc(validate_state(raw_state))
|
|
assert len(d["annuals"]) == 5
|
|
|
|
@given(raw_state=plausible_state)
|
|
@settings(max_examples=200, deadline=2000)
|
|
def test_capex_items_sum(self, raw_state):
|
|
d = calc(validate_state(raw_state))
|
|
assert sum(i["amount"] for i in d["capexItems"]) == d["capex"]
|
|
|
|
@given(raw_state=plausible_state)
|
|
@settings(max_examples=200, deadline=2000)
|
|
def test_opex_items_sum(self, raw_state):
|
|
d = calc(validate_state(raw_state))
|
|
assert sum(i["amount"] for i in d["opexItems"]) == d["opex"]
|
|
|
|
@given(raw_state=plausible_state)
|
|
@settings(max_examples=200, deadline=2000)
|
|
def test_equity_loan_identity(self, raw_state):
|
|
d = calc(validate_state(raw_state))
|
|
assert d["equity"] + d["loanAmount"] == d["capex"]
|
|
|
|
@given(raw_state=plausible_state)
|
|
@settings(max_examples=200, deadline=2000)
|
|
def test_no_nan_or_inf(self, raw_state):
|
|
d = calc(validate_state(raw_state))
|
|
_assert_finite(d)
|
|
|
|
@given(raw_state=plausible_state)
|
|
@settings(max_examples=200, deadline=2000)
|
|
def test_json_serializable(self, raw_state):
|
|
d = calc(validate_state(raw_state))
|
|
json.dumps(d) # must not raise
|
|
|
|
@given(raw_state=plausible_state)
|
|
@settings(max_examples=200, deadline=2000)
|
|
def test_cumulative_cf_integrity(self, raw_state):
|
|
d = calc(validate_state(raw_state))
|
|
months = d["months"]
|
|
assert months[0]["cum"] == approx(-d["capex"] + months[0]["ncf"])
|
|
for i in range(1, 60):
|
|
assert months[i]["cum"] == approx(months[i - 1]["cum"] + months[i]["ncf"])
|
|
|
|
@given(raw_state=plausible_state)
|
|
@settings(max_examples=200, deadline=2000)
|
|
def test_total_courts_matches_input(self, raw_state):
|
|
s = validate_state(raw_state)
|
|
d = calc(s)
|
|
assert d["totalCourts"] == s["dblCourts"] + s["sglCourts"]
|
|
|
|
@given(raw_state=plausible_state)
|
|
@settings(max_examples=100, deadline=2000)
|
|
def test_monthly_ebitda_is_rev_plus_opex(self, raw_state):
|
|
d = calc(validate_state(raw_state))
|
|
for m in d["months"]:
|
|
assert m["ebitda"] == approx(m["totalRev"] + m["opex"])
|
|
|
|
@given(raw_state=plausible_state)
|
|
@settings(max_examples=100, deadline=2000)
|
|
def test_monthly_ncf_is_ebitda_plus_loan(self, raw_state):
|
|
d = calc(validate_state(raw_state))
|
|
for m in d["months"]:
|
|
assert m["ncf"] == approx(m["ebitda"] + m["loan"])
|