""" 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_exit_year(self, d): # stabEbitda uses the terminal year (holdYears - 1), not hardcoded Year 3. s = default_state() exit_idx = min(s["holdYears"] - 1, len(d["annuals"]) - 1) assert d["stabEbitda"] == d["annuals"][exit_idx]["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"])