Files
padelnomics/web/tests/test_calculator.py
Deeman c25e20f83a fix: playtomic pagination stale-page exit + calculator test assertions
Playtomic tenants API recycles results past its internal limit —
stop after 3 consecutive pages with zero new unique IDs.

Calculator tests: replace hardcoded default values (6 courts, specific
sqm/capex) with DEFAULTS references so tests don't break when
defaults change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:06:48 +01:00

1127 lines
45 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"] == DEFAULTS["dblCourts"] + DEFAULTS["sglCourts"]
def test_sqm_is_hall(self, d):
# Indoor venue → sqm is hallSqm
dbl = DEFAULTS["dblCourts"]
sgl = DEFAULTS["sglCourts"]
total = dbl + sgl
expected = dbl * DEFAULTS["sqmPerDblHall"] + sgl * DEFAULTS["sqmPerSglHall"] + 200 + total * 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"] / d["totalCourts"])
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):
dbl = DEFAULTS["dblCourts"]
sgl = DEFAULTS["sglCourts"]
total = dbl + sgl
dbl_rate = DEFAULTS["ratePeak"] * (DEFAULTS["peakPct"] / 100) + DEFAULTS["rateOffPeak"] * (1 - DEFAULTS["peakPct"] / 100)
expected = (dbl * dbl_rate + sgl * DEFAULTS["rateSingle"]) / total if total > 0 else 0
assert d["weightedRate"] == approx(expected)
def test_avail_hours_month(self, d):
total = DEFAULTS["dblCourts"] + DEFAULTS["sglCourts"]
assert d["availHoursMonth"] == DEFAULTS["hoursPerDay"] * DEFAULTS["daysPerMonthIndoor"] * total
def test_booked_hours_month(self, d):
assert d["bookedHoursMonth"] == approx(d["availHoursMonth"] * 0.4)
def test_revenue_components(self, d):
total = d["totalCourts"]
assert d["courtRevMonth"] == approx(d["bookedHoursMonth"] * d["weightedRate"])
assert d["feeDeduction"] == approx(d["courtRevMonth"] * DEFAULTS["bookingFee"] / 100)
assert d["membershipRev"] == total * DEFAULTS["membershipRevPerCourt"]
assert d["fbRev"] == total * DEFAULTS["fbRevPerCourt"]
assert d["coachingRev"] == total * DEFAULTS["coachingRevPerCourt"]
assert d["retailRev"] == total * DEFAULTS["retailRevPerCourt"]
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):
dbl = DEFAULTS["dblCourts"]
sgl = DEFAULTS["sglCourts"]
expected = dbl * DEFAULTS["sqmPerDblOutdoor"] + sgl * DEFAULTS["sqmPerSglOutdoor"] + 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
def test_permits_compliance_in_indoor_rent(self, d):
names = [i["name"] for i in d["capexItems"]]
assert "Permits & Compliance" in names
def test_permits_compliance_amount(self, d):
permits = next(i for i in d["capexItems"] if i["name"] == "Permits & Compliance")
assert permits["amount"] == DEFAULTS["permitsCompliance"]
# ════════════════════════════════════════════════════════════
# calc — permits & compliance across scenarios
# ════════════════════════════════════════════════════════════
class TestPermitsCompliance:
def test_indoor_rent_has_permits(self):
d = calc(default_state(venue="indoor", own="rent"))
names = [i["name"] for i in d["capexItems"]]
assert "Permits & Compliance" in names
def test_outdoor_rent_has_permits(self):
d = calc(default_state(venue="outdoor", own="rent"))
names = [i["name"] for i in d["capexItems"]]
assert "Permits & Compliance" in names
def test_outdoor_buy_has_permits(self):
d = calc(default_state(venue="outdoor", own="buy"))
names = [i["name"] for i in d["capexItems"]]
assert "Permits & Compliance" in names
def test_indoor_buy_no_permits_compliance(self):
"""Indoor Buy already has Planning + Permits, so no separate Permits & Compliance."""
d = calc(default_state(venue="indoor", own="buy"))
names = [i["name"] for i in d["capexItems"]]
assert "Permits & Compliance" not in names
assert "Planning + Permits" in names
def test_permits_compliance_value_adjustable(self):
d = calc(default_state(venue="indoor", own="rent", permitsCompliance=25000))
permits = next(i for i in d["capexItems"] if i["name"] == "Permits & Compliance")
assert permits["amount"] == 25000
def test_country_in_defaults(self):
assert "country" in DEFAULTS
assert DEFAULTS["country"] == "DE"
def test_permits_compliance_in_defaults(self):
assert "permitsCompliance" in DEFAULTS
assert DEFAULTS["permitsCompliance"] == 12000
# ════════════════════════════════════════════════════════════
# 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):
total = d["totalCourts"]
expected = d["breakEvenHrs"] / total / DEFAULTS["daysPerMonthIndoor"]
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_positive(self, d):
assert d["capex"] > 0
def test_total_courts_matches_defaults(self, d):
assert d["totalCourts"] == DEFAULTS["dblCourts"] + DEFAULTS["sglCourts"]
def test_hall_sqm_positive(self, d):
assert d["hallSqm"] > 0
def test_opex_value(self, d):
assert d["opex"] > 0
def test_payback_idx_valid(self, d):
idx = d["paybackIdx"]
assert idx >= 0
assert idx < 60
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_matches_input(self, d):
assert d["totalCourts"] == DEFAULTS["dblCourts"] + DEFAULTS["sglCourts"]
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),
"budgetTarget": st.integers(0, 5000000),
"glassType": st.sampled_from(["standard", "panoramic"]),
"lightingType": st.sampled_from(["led_standard", "led_competition", "natural"]),
"country": st.sampled_from(["DE", "ES", "IT", "FR", "NL", "SE", "UK", "US"]),
"permitsCompliance": st.integers(0, 50000),
})
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"])