From 9a558c5bb55908e76b52f3d2d23d8dbc2d5ab826 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 1 Aug 2025 18:44:49 +0100 Subject: [PATCH] added base unit tests --- backend/tests/test_funding.py | 312 ++++++++++++++++++++++++++++------ 1 file changed, 263 insertions(+), 49 deletions(-) diff --git a/backend/tests/test_funding.py b/backend/tests/test_funding.py index da5a71e1..7a18fa55 100644 --- a/backend/tests/test_funding.py +++ b/backend/tests/test_funding.py @@ -1,64 +1,278 @@ import pytest import pandas as pd -from utils.s3 import read_csv_from_s3 -from backend.Funding import Funding +from backend.Funding import Funding, EligibilityCaveats -def get_funding_data(): - """ - This function retrieves the eco project scores matrix and the warm homes local grant funding data - :return: - """ - project_scores_matrix = read_csv_from_s3( - bucket_name="retrofit-data-dev", - filepath="funding/ECO4 Full Project Scores Matrix.csv", +@pytest.fixture +def mock_project_scores_matrix(): + data = [] + floor_segments = ["0-72", "73-97", "98-199", "200"] + starting_bands = ["Low_G", "High_G", "Low_F", "High_F", "Low_E", "High_E", "Low_D", "High_D", "Low_C", "High_C"] + finishing_bands = ["Low_C", "High_C", "Low_B"] # covers likely improvement targets + + cost = 50.0 + for floor in floor_segments: + for start in starting_bands: + for finish in finishing_bands: + if start != finish: # skip identical start/finish (no SAP movement) + data.append({ + "Floor Area Segment": floor, + "Starting Band": start, + "Finishing Band": finish, + "Cost Savings": cost + }) + cost += 5.0 # increment to create variety + + return pd.DataFrame(data) + + +@pytest.fixture +def mock_partial_scores_matrix(): + return pd.DataFrame([{"dummy": "data"}]) # not used for eligibility tests yet + + +@pytest.fixture +def mock_whlg_postcodes(): + return pd.DataFrame([{"Postcode": "ab12cd"}]) + + +### ------------------------- +### PRIVATE RENTED SECTOR (PRS) +### ------------------------- + +def test_eco4_prs_eligible_with_swi(mock_project_scores_matrix, mock_partial_scores_matrix, mock_whlg_postcodes): + funding = Funding( + project_scores_matrix=mock_project_scores_matrix, + partial_project_scores_matrix=mock_partial_scores_matrix, + whlg_eligible_postcodes=mock_whlg_postcodes, + social_cavity_abs_rate=13.5, + social_solid_abs_rate=17, + private_cavity_abs_rate=13.5, + private_solid_abs_rate=17, + tenure="Private", ) - project_scores_matrix = pd.DataFrame(project_scores_matrix) - project_scores_matrix.columns = ['Floor Area Segment', 'Starting Band', 'Finishing Band', 'Cost Savings'] - project_scores_matrix["Cost Savings"] = project_scores_matrix["Cost Savings"].astype(float) - partial_project_scores_matrix = read_csv_from_s3( - bucket_name="retrofit-data-dev", - filepath="funding/ECO4_Partial_Project_Scores_Matrix_v6.csv", + # The property is: + # 1) private, + # 2) EPC E + # 3) is getting a solid was measure + # so it's eligible for ECO4 + + measures = [{"type": "internal_wall_insulation", "is_innovation": False}] + funding.check_funding( + measures=measures, + starting_sap=50, # EPC E + ending_sap=69, + floor_area=80, + mainheat_description="Boiler and radiators, mains gas", + heating_control_description="Programmer, room thermostat and TRVs", + is_cavity=True, + council_tax_band="B" ) - partial_project_scores_matrix = pd.DataFrame(partial_project_scores_matrix) - partial_project_scores_matrix["Cost Savings"] = partial_project_scores_matrix["Cost Savings"].astype(float) - whlg_eligible_postcodes = read_csv_from_s3( - bucket_name="retrofit-data-dev", - filepath="funding/whlg eligible postcodes.csv", + assert funding.eco4_eligible + assert EligibilityCaveats.TENANT_ON_BENEFITS_OR_LOW_INCOME in funding.eco4_eligibility_caveats + + +def test_eco4_prs_not_eligible_high_epc(mock_project_scores_matrix, mock_partial_scores_matrix, mock_whlg_postcodes): + """Should NOT be eligible if EPC is too high (C or above).""" + funding = Funding( + project_scores_matrix=mock_project_scores_matrix, + partial_project_scores_matrix=mock_partial_scores_matrix, + whlg_eligible_postcodes=mock_whlg_postcodes, + social_cavity_abs_rate=13.5, + social_solid_abs_rate=17, + private_cavity_abs_rate=13.5, + private_solid_abs_rate=17, + tenure="Private", ) - whlg_eligible_postcodes = pd.DataFrame(whlg_eligible_postcodes) - return project_scores_matrix, partial_project_scores_matrix, whlg_eligible_postcodes + measures = [{"type": "internal_wall_insulation", "is_innovation": False}] + funding.check_funding( + measures=measures, + starting_sap=72, # EPC C (too high) + ending_sap=75, + floor_area=80, + mainheat_description="Boiler and radiators, mains gas", + heating_control_description="Programmer, room thermostat and TRVs", + is_cavity=True, + council_tax_band="B" + ) + + assert not funding.eco4_eligible -class TestFunding: +def test_gbis_prs_general_eligibility(mock_project_scores_matrix, mock_partial_scores_matrix, mock_whlg_postcodes): + """PRS EPC D–G & council tax band A–D should trigger GBIS general route.""" + funding = Funding( + project_scores_matrix=mock_project_scores_matrix, + partial_project_scores_matrix=mock_partial_scores_matrix, + whlg_eligible_postcodes=mock_whlg_postcodes, + social_cavity_abs_rate=13.5, + social_solid_abs_rate=17, + private_cavity_abs_rate=13.5, + private_solid_abs_rate=17, + tenure="Private", + ) - def test_prs(self): - fps_matrix, pps_matrix, whlg_eligible_postcodes = get_funding_data() - funding = Funding( - project_scores_matrix=fps_matrix, - partial_project_scores_matrix=pps_matrix, - whlg_eligible_postcodes=whlg_eligible_postcodes, - social_cavity_abs_rate=13.5, - social_solid_abs_rate=17, - private_cavity_abs_rate=13.5, - private_solid_abs_rate=17, - tenure="Private", - ) + measures = [{"type": "internal_wall_insulation", "is_innovation": False}] + funding.check_funding( + measures=measures, + starting_sap=65, # EPC D + ending_sap=70, + floor_area=80, + mainheat_description="Boiler and radiators, mains gas", + heating_control_description="Programmer, room thermostat and TRVs", + is_cavity=True, + council_tax_band="A" + ) - measures_1 = [ - {"type": "internal_wall_insulation", "is_innovation": False}, - {"type": "solar_pv", "is_innovation": True}, - ] + assert funding.gbis_eligible - funding.check_funding( - measures=measures_1, - starting_sap=54, - ending_sap=69, - floor_area=73, - mainheat_description="Boiler and radiators, mains gas", - heating_control_description="Programmer, room thermostat and TRVs", - is_cavity=True - ) + +def test_gbis_prs_low_income_caveat(mock_project_scores_matrix, mock_partial_scores_matrix, mock_whlg_postcodes): + """PRS EPC D–G should flag low-income caveat when low-income route is used.""" + funding = Funding( + project_scores_matrix=mock_project_scores_matrix, + partial_project_scores_matrix=mock_partial_scores_matrix, + whlg_eligible_postcodes=mock_whlg_postcodes, + social_cavity_abs_rate=13.5, + social_solid_abs_rate=17, + private_cavity_abs_rate=13.5, + private_solid_abs_rate=17, + tenure="Private", + ) + + measures = [{"type": "cavity_wall_insulation", "is_innovation": False}] + funding.check_funding( + measures=measures, + starting_sap=60, # EPC D + ending_sap=70, + floor_area=80, + mainheat_description="Boiler and radiators, mains gas", + heating_control_description="Programmer, room thermostat and TRVs", + is_cavity=True, + council_tax_band="B" + ) + + assert funding.gbis_eligible + assert EligibilityCaveats.TENANT_ON_BENEFITS_OR_LOW_INCOME in funding.gbis_eligibility_caveats + + +### ------------------------- +### SOCIAL HOUSING +### ------------------------- + +def test_eco4_sh_epc_e_eligible(mock_project_scores_matrix, mock_partial_scores_matrix, mock_whlg_postcodes): + """EPC E social housing should be ECO4 eligible without innovation.""" + funding = Funding( + project_scores_matrix=mock_project_scores_matrix, + partial_project_scores_matrix=mock_partial_scores_matrix, + whlg_eligible_postcodes=mock_whlg_postcodes, + social_cavity_abs_rate=13.5, + social_solid_abs_rate=17, + private_cavity_abs_rate=13.5, + private_solid_abs_rate=17, + tenure="Social", + ) + + measures = [{"type": "internal_wall_insulation", "is_innovation": False}] + funding.check_funding( + measures=measures, + starting_sap=50, # EPC E + ending_sap=69, + floor_area=80, + mainheat_description="Boiler and radiators, mains gas", + heating_control_description="Programmer, room thermostat and TRVs", + is_cavity=True + ) + + assert funding.eco4_eligible + + +def test_eco4_sh_epc_d_requires_innovation(mock_project_scores_matrix, mock_partial_scores_matrix, mock_whlg_postcodes): + """EPC D social housing should require an innovation measure.""" + funding = Funding( + project_scores_matrix=mock_project_scores_matrix, + partial_project_scores_matrix=mock_partial_scores_matrix, + whlg_eligible_postcodes=mock_whlg_postcodes, + social_cavity_abs_rate=13.5, + social_solid_abs_rate=17, + private_cavity_abs_rate=13.5, + private_solid_abs_rate=17, + tenure="Social", + ) + + measures = [{"type": "internal_wall_insulation", "is_innovation": False}] + funding.check_funding( + measures=measures, + starting_sap=60, # EPC D + ending_sap=69, + floor_area=80, + mainheat_description="Boiler and radiators, mains gas", + heating_control_description="Programmer, room thermostat and TRVs", + is_cavity=True + ) + + assert not funding.eco4_eligible + assert EligibilityCaveats.INNOVATION_REQUIRED in funding.eco4_eligibility_caveats + + +def test_eco4_sh_solar_pv_requires_heating(mock_project_scores_matrix, mock_partial_scores_matrix, mock_whlg_postcodes): + """Solar PV as innovation measure requires ASHP or HHRSH.""" + funding = Funding( + project_scores_matrix=mock_project_scores_matrix, + partial_project_scores_matrix=mock_partial_scores_matrix, + whlg_eligible_postcodes=mock_whlg_postcodes, + social_cavity_abs_rate=13.5, + social_solid_abs_rate=17, + private_cavity_abs_rate=13.5, + private_solid_abs_rate=17, + tenure="Social", + ) + + measures = [{"type": "solar_pv", "is_innovation": True}] + funding.check_funding( + measures=measures, + starting_sap=60, # EPC D + ending_sap=69, + floor_area=80, + mainheat_description="Boiler and radiators, mains gas", + heating_control_description="Programmer, room thermostat and TRVs", + is_cavity=True + ) + + assert not funding.eco4_eligible + assert EligibilityCaveats.SOLAR_NEEDS_HEATING in funding.eco4_eligibility_caveats + + +def test_eco4_sh_solar_pv_with_heating_is_ok(mock_project_scores_matrix, mock_partial_scores_matrix, + mock_whlg_postcodes): + """Solar PV innovation with ASHP should pass EPC D innovation rule.""" + funding = Funding( + project_scores_matrix=mock_project_scores_matrix, + partial_project_scores_matrix=mock_partial_scores_matrix, + whlg_eligible_postcodes=mock_whlg_postcodes, + social_cavity_abs_rate=13.5, + social_solid_abs_rate=17, + private_cavity_abs_rate=13.5, + private_solid_abs_rate=17, + tenure="Social", + ) + + measures = [ + {"type": "solar_pv", "is_innovation": True}, + {"type": "air_source_heat_pump", "is_innovation": False} + ] + funding.check_funding( + measures=measures, + starting_sap=60, # EPC D + ending_sap=69, + floor_area=80, + mainheat_description="Boiler and radiators, mains gas", + heating_control_description="Programmer, room thermostat and TRVs", + is_cavity=True + ) + + assert funding.eco4_eligible + assert EligibilityCaveats.SOLAR_NEEDS_HEATING not in funding.eco4_eligibility_caveats