import json import os from datetime import date import pytest from backend.documents_parser.extractor import PasHubRdSapSiteNotesExtractor from datatypes.epc.surveys.pashub_rdsap_site_notes import ( BuildingConstruction, BuildingMeasurements, Conservatories, CustomerResponse, ExtensionConstruction, ExtensionMeasurements, ExtensionRoofSpace, FloorConstruction, FloorMeasurement, General, HeatingAndHotWater, InspectionMetadata, MainBuildingConstruction, MainBuildingMeasurements, MainHeating, PvArrayDetail, RoomInRoofSurfaceDetail, Renewables, RoomCountElements, RoofSpace, RoofSpaceDetail, SecondaryHeating, Shower, SurveyAddendum, Ventilation, WaterHeating, WaterUse, ) FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures") def load_text_fixture() -> list[str]: with open(os.path.join(FIXTURES, "pashub_site_notes_1_text.json")) as f: return json.load(f) def load_text_fixture_2() -> list[str]: with open(os.path.join(FIXTURES, "pashub_site_notes_2_text.json")) as f: return json.load(f) def load_text_fixture_3() -> list[str]: with open(os.path.join(FIXTURES, "pashub_site_notes_3_text.json")) as f: return json.load(f) def load_text_fixture_4() -> list[str]: with open(os.path.join(FIXTURES, "pashub_site_notes_4_text.json")) as f: return json.load(f) def load_text_fixture_5() -> list[str]: with open(os.path.join(FIXTURES, "pashub_site_notes_5_text.json")) as f: return json.load(f) def load_text_fixture_6() -> list[str]: with open(os.path.join(FIXTURES, "pashub_site_notes_6_text.json")) as f: return json.load(f) def load_text_fixture_7() -> list[str]: with open(os.path.join(FIXTURES, "pashub_site_notes_7_text.json")) as f: return json.load(f) class TestInspectionMetadata: def test_full_inspection_metadata(self) -> None: result = PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_inspection_metadata() assert result == InspectionMetadata( inspection_surveyor="Benjamin Burke", email_address="ben@mbsolutionsgroup.co.uk", report_reference="6EA2A86D-94CE-4792-8D49-AB495C744EDD", created_on="2025-11-10", date_of_inspection=date(2025, 9, 25), property_address="40, Abbey Place, Crewe, Cheshire, CW1 4JR", property_photo=True, ) class TestGeneral: @pytest.fixture def general(self) -> General: return PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_general() def test_epc_checked_before_assessment(self, general: General) -> None: assert general.epc_checked_before_assessment is True def test_epc_exists_at_point_of_assessment(self, general: General) -> None: assert general.epc_exists_at_point_of_assessment is False def test_inspection_date(self, general: General) -> None: assert general.inspection_date == date(2025, 9, 25) def test_transaction_type(self, general: General) -> None: assert general.transaction_type == "Grant-Scheme (ECO, RHI, etc.)" def test_tenure(self, general: General) -> None: assert general.tenure == "Rented Social" def test_property_type(self, general: General) -> None: assert general.property_type == "House" def test_detachment_type(self, general: General) -> None: assert general.detachment_type == "Mid-terrace" def test_number_of_storeys(self, general: General) -> None: assert general.number_of_storeys == 2 def test_number_of_extensions(self, general: General) -> None: assert general.number_of_extensions == 1 def test_electricity_smart_meter(self, general: General) -> None: assert general.electricity_smart_meter is True def test_mains_gas_available(self, general: General) -> None: assert general.mains_gas_available is True def test_measurements_location(self, general: General) -> None: assert general.measurements_location == "Internal" def test_full_general(self, general: General) -> None: assert general == General( epc_checked_before_assessment=True, epc_exists_at_point_of_assessment=False, inspection_date=date(2025, 9, 25), transaction_type="Grant-Scheme (ECO, RHI, etc.)", tenure="Rented Social", property_type="House", detachment_type="Mid-terrace", number_of_storeys=2, terrain_type="Suburban", number_of_extensions=1, electricity_smart_meter=True, electric_meter_type="Single", dwelling_export_capable=True, mains_gas_available=True, gas_smart_meter=True, gas_meter_accessible=True, measurements_location="Internal", ) class TestGeneralNoExtensions: @pytest.fixture def general(self) -> General: return PasHubRdSapSiteNotesExtractor(load_text_fixture_2()).extract_general() def test_number_of_extensions_when_no_extensions(self, general: General) -> None: assert general.number_of_extensions == 0 class TestBuildingConstruction: @pytest.fixture def construction(self) -> BuildingConstruction: return PasHubRdSapSiteNotesExtractor( load_text_fixture() ).extract_building_construction() def test_main_building_wall_u_value_known_is_false( self, construction: BuildingConstruction ) -> None: assert construction.main_building.wall_u_value_known is False def test_main_building_wall_thickness_mm( self, construction: BuildingConstruction ) -> None: assert construction.main_building.wall_thickness_mm == 310 def test_main_building_filled_cavity_indicators_present( self, construction: BuildingConstruction ) -> None: assert ( construction.main_building.filled_cavity_indicators == "evidence of cavity fill drill holes" ) def test_extension_filled_cavity_indicators_absent( self, construction: BuildingConstruction ) -> None: assert construction.extensions is not None assert construction.extensions[0].filled_cavity_indicators is None def test_one_extension(self, construction: BuildingConstruction) -> None: assert construction.extensions is not None assert len(construction.extensions) == 1 def test_extension_id(self, construction: BuildingConstruction) -> None: assert construction.extensions is not None assert construction.extensions[0].id == 1 def test_wall_dry_lined_answers_are_captured(self) -> None: # Arrange / Act — fixture 7 lodges "Wall Dry-Lined?" per building # part: Main "No", Extension 1 "Yes", Extension 2 "Yes". Dropped, a # dry-lined wall is billed at the full uninsulated Table 6 U instead # of the RdSAP10 §5.8 / Table 14 dry-lining-adjusted value (19% of # the Guinness accuracy cohort lodges a "Yes"). construction = PasHubRdSapSiteNotesExtractor( load_text_fixture_7() ).extract_building_construction() # Assert assert construction.main_building.dry_lined is False assert construction.extensions is not None assert [e.dry_lined for e in construction.extensions] == [True, True] def test_wall_dry_lined_not_asked_stays_unknown( self, construction: BuildingConstruction ) -> None: # Fixture 1 never asks "Wall Dry-Lined?" — unknown, NOT a "No". assert construction.main_building.dry_lined is None assert construction.extensions is not None assert construction.extensions[0].dry_lined is None def test_full_building_construction( self, construction: BuildingConstruction ) -> None: assert construction == BuildingConstruction( main_building=MainBuildingConstruction( age_range="1950-1966", age_indicators="local knowledge, enquiries of owner", walls_construction_type="Cavity", cavity_construction_indicators="wall thickness over 270 mm", walls_insulation_type="Filled Cavity", filled_cavity_indicators="evidence of cavity fill drill holes", thermal_conductivity_of_wall_insulation="Unknown", wall_u_value_known=False, wall_thickness_mm=310, party_wall_construction_type="Cavity Masonry, Filled", ), floor=FloorConstruction( floor_type="Ground Floor", floor_construction="Solid", floor_insulation_type="As Built", floor_u_value_known=False, ), extensions=[ ExtensionConstruction( id=1, age_range="2003-2006", age_indicators="local knowledge, enquiries of owner", walls_construction_type="Cavity", cavity_construction_indicators="wall thickness over 270 mm", walls_insulation_type="As built", thermal_conductivity_of_wall_insulation="Unknown", wall_u_value_known=False, wall_thickness_mm=310, party_wall_construction_type="Cavity Masonry, Filled", filled_cavity_indicators=None, ) ], ) class TestBuildingMeasurements: @pytest.fixture def measurements(self) -> BuildingMeasurements: return PasHubRdSapSiteNotesExtractor( load_text_fixture() ).extract_building_measurements() def test_main_building_has_two_floors( self, measurements: BuildingMeasurements ) -> None: assert len(measurements.main_building.floors) == 2 def test_main_building_floor_area( self, measurements: BuildingMeasurements ) -> None: assert measurements.main_building.floors[0].area_m2 == 35.68 def test_integer_token_parses_to_float( self, measurements: BuildingMeasurements ) -> None: # "11" in the PDF (no decimal) should parse to 11.0 assert measurements.main_building.floors[1].heat_loss_perimeter_m == 11.0 def test_extension_measurements_present( self, measurements: BuildingMeasurements ) -> None: assert measurements.extensions is not None assert len(measurements.extensions) == 1 def test_extension_id(self, measurements: BuildingMeasurements) -> None: assert measurements.extensions is not None assert measurements.extensions[0].id == 1 def test_full_building_measurements( self, measurements: BuildingMeasurements ) -> None: assert measurements == BuildingMeasurements( main_building=MainBuildingMeasurements( floors=[ FloorMeasurement( name="Floor 1", area_m2=35.68, height_m=2.19, heat_loss_perimeter_m=13.44, pwl_m=10.62, ), FloorMeasurement( name="Floor 0", area_m2=35.68, height_m=2.17, heat_loss_perimeter_m=11.0, pwl_m=10.62, ), ] ), extensions=[ ExtensionMeasurements( id=1, floors=[ FloorMeasurement( name="Floor 0", area_m2=3.8, height_m=2.0, heat_loss_perimeter_m=5.7, pwl_m=0.0, ) ], ) ], ) class TestRoofSpace: @pytest.fixture def roof_space(self) -> RoofSpace: return PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_roof_space() def test_main_building_insulation_thickness_mm( self, roof_space: RoofSpace ) -> None: assert roof_space.main_building.insulation_thickness_mm == 100 def test_main_building_insulation_thickness_string_absent( self, roof_space: RoofSpace ) -> None: assert roof_space.main_building.insulation_thickness is None def test_main_building_rooms_in_roof(self, roof_space: RoofSpace) -> None: assert roof_space.main_building.rooms_in_roof is False def test_main_building_roof_u_value_known(self, roof_space: RoofSpace) -> None: assert roof_space.main_building.roof_u_value_known is False def test_extension_uses_string_thickness(self, roof_space: RoofSpace) -> None: assert roof_space.extensions is not None assert roof_space.extensions[0].insulation_thickness == "As built" assert roof_space.extensions[0].insulation_thickness_mm is None def test_full_roof_space(self, roof_space: RoofSpace) -> None: assert roof_space == RoofSpace( main_building=RoofSpaceDetail( construction_type="Pitched roof (Slates or tiles), Access to loft", insulation_at="Joists", roof_u_value_known=False, cavity_wall_construction_indicators="cavity visible in roof space", rooms_in_roof=False, insulation_thickness_mm=100, insulation_thickness=None, ), extensions=[ ExtensionRoofSpace( id=1, construction_type="Pitched roof, Sloping ceiling", insulation_at="Sloping ceiling insulation", roof_u_value_known=False, cavity_wall_construction_indicators="No indicator of construction visible", rooms_in_roof=False, insulation_thickness_mm=None, insulation_thickness="As built", ) ], ) class TestWindows: @pytest.fixture def windows(self) -> list: return PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_windows() def test_window_count(self, windows: list) -> None: assert len(windows) == 8 def test_ids_are_sequential(self, windows: list) -> None: assert [w.id for w in windows] == list(range(1, 9)) def test_first_window_location(self, windows: list) -> None: assert windows[0].location == "Main Building" def test_extension_window_location(self, windows: list) -> None: assert windows[3].location == "Extension 1" def test_height_parses_to_float(self, windows: list) -> None: assert windows[0].height_m == 1.2 def test_draught_proofed_true(self, windows: list) -> None: assert windows[0].draught_proofed is True def test_permanent_shutters_false(self, windows: list) -> None: assert windows[0].permanent_shutters is False def test_first_window_full(self, windows: list) -> None: from datatypes.epc.surveys.pashub_rdsap_site_notes import Window assert windows[0] == Window( id=1, location="Main Building", wall_type="External wall", glazing_type="Double glazing, Unknown install date", window_type="Window", frame_type="Wooden or PVC", glazing_gap="16 mm or more", draught_proofed=True, permanent_shutters=False, height_m=1.2, width_m=2.3, orientation="North West", ) class TestWaterHeatingCylinderThickness: @pytest.fixture def hhw(self) -> HeatingAndHotWater: return PasHubRdSapSiteNotesExtractor( load_text_fixture_2() ).extract_heating_and_hot_water() @pytest.fixture def hhw_no_cylinder(self) -> HeatingAndHotWater: return PasHubRdSapSiteNotesExtractor( load_text_fixture() ).extract_heating_and_hot_water() def test_cylinder_insulation_thickness_mm(self, hhw: HeatingAndHotWater) -> None: assert hhw.water_heating.insulation_thickness_mm == 38 def test_cylinder_insulation_thickness_mm_absent(self, hhw_no_cylinder: HeatingAndHotWater) -> None: assert hhw_no_cylinder.water_heating.insulation_thickness_mm is None def test_cylinder_size(self, hhw: HeatingAndHotWater) -> None: assert hhw.water_heating.cylinder_size == "Normal (90-130 litres)" class TestImmersionType: @pytest.fixture def hhw(self) -> HeatingAndHotWater: return PasHubRdSapSiteNotesExtractor( load_text_fixture_3() ).extract_heating_and_hot_water() def test_immersion_type(self, hhw: HeatingAndHotWater) -> None: assert hhw.water_heating.immersion_type == "Dual" class TestCylinderThermostat: @pytest.fixture def hhw(self) -> HeatingAndHotWater: return PasHubRdSapSiteNotesExtractor( load_text_fixture_3() ).extract_heating_and_hot_water() def test_has_thermostat_true(self, hhw: HeatingAndHotWater) -> None: assert hhw.water_heating.has_thermostat is True class TestSecondaryHeating: @pytest.fixture def hhw(self) -> HeatingAndHotWater: return PasHubRdSapSiteNotesExtractor( load_text_fixture_2() ).extract_heating_and_hot_water() @pytest.fixture def hhw_no_secondary(self) -> HeatingAndHotWater: return PasHubRdSapSiteNotesExtractor( load_text_fixture() ).extract_heating_and_hot_water() def test_secondary_system(self, hhw: HeatingAndHotWater) -> None: assert hhw.secondary_heating.secondary_system == "Open fire in grate" def test_secondary_system_absent(self, hhw_no_secondary: HeatingAndHotWater) -> None: assert hhw_no_secondary.secondary_heating.secondary_system is None class TestHeatingAndHotWater: @pytest.fixture def hhw(self) -> HeatingAndHotWater: return PasHubRdSapSiteNotesExtractor( load_text_fixture() ).extract_heating_and_hot_water() def test_product_id_parses_to_int(self, hhw: HeatingAndHotWater) -> None: assert hhw.main_heating.product_id == 16839 def test_summer_efficiency_parses_to_float(self, hhw: HeatingAndHotWater) -> None: assert hhw.main_heating.summer_efficiency == 0.0 def test_condensing_true(self, hhw: HeatingAndHotWater) -> None: assert hhw.main_heating.condensing is True def test_fghrs_false(self, hhw: HeatingAndHotWater) -> None: # multi-line label assert hhw.main_heating.flue_gas_heat_recovery_system is False def test_secondary_fuel(self, hhw: HeatingAndHotWater) -> None: assert hhw.secondary_heating.secondary_fuel == "No Secondary Heating" def test_water_heating_no_cylinder(self, hhw: HeatingAndHotWater) -> None: assert hhw.water_heating.cylinder_size == "No Cylinder" assert hhw.water_heating.insulation_type is None assert hhw.water_heating.has_thermostat is None def test_full_heating_and_hot_water(self, hhw: HeatingAndHotWater) -> None: assert hhw == HeatingAndHotWater( main_heating=MainHeating( selection_method="PCDF Search", system_type="Boiler with radiators or underfloor heating", product_id=16839, manufacturer="Vaillant", model="ecoTEC pro 28", orig_manufacturer="Vaillant", fuel="Mains gas", summer_efficiency=0.0, type="Combi", condensing=True, year="2005 - 2015", mount="Wall", open_flue="Room-sealed", fan_assist=True, status="Normal status for an actual product", central_heating_pump_age="Unknown", controls="Programmer, room thermostat and TRVs", flue_gas_heat_recovery_system=False, weather_compensator=False, emitter="Radiators", emitter_temperature="Unknown", ), secondary_heating=SecondaryHeating( secondary_fuel="No Secondary Heating", ), water_heating=WaterHeating( type="Regular", system="From main heating 1", cylinder_size="No Cylinder", cylinder_measured_heat_loss=None, insulation_type=None, insulation_thickness_mm=None, has_thermostat=None, ), ) class TestMainHeatingFuelLabelVariant: """The newer PAS Hub site-note form lodges the main-heating fuel under a `Fuel:` label (with colon) rather than the usual `Fuel`. The extractor must recognise both, else the fuel is silently dropped and the property presents as fuel-less downstream — the 3 Guinness cohort "blank-fuel" residuals that were really Mains Gas all along (issue #1558).""" def _section(self, fuel_label: str) -> list[str]: return [ "Heating & Hot Water", "System type:", "Boiler with radiators or underfloor heating", fuel_label, "Mains Gas", "Ventilation", ] def test_colon_fuel_label_captures_value(self) -> None: # Arrange tokens = self._section("Fuel:") # Act hhw = PasHubRdSapSiteNotesExtractor(tokens).extract_heating_and_hot_water() # Assert assert hhw.main_heating.fuel == "Mains Gas" def test_plain_fuel_label_still_captures_value(self) -> None: # Arrange tokens = self._section("Fuel") # Act hhw = PasHubRdSapSiteNotesExtractor(tokens).extract_heating_and_hot_water() # Assert assert hhw.main_heating.fuel == "Mains Gas" class TestVentilation: @pytest.fixture def ventilation(self) -> Ventilation: return PasHubRdSapSiteNotesExtractor( load_text_fixture() ).extract_ventilation() def test_ventilation_type(self, ventilation: Ventilation) -> None: assert ventilation.ventilation_type == "Mechanical Extract - Decentralised" def test_number_of_open_flues(self, ventilation: Ventilation) -> None: assert ventilation.number_of_open_flues == 0 def test_ventilation_in_pcdf_database(self, ventilation: Ventilation) -> None: assert ventilation.ventilation_in_pcdf_database is False def test_full_ventilation(self, ventilation: Ventilation) -> None: assert ventilation == Ventilation( ventilation_type="Mechanical Extract - Decentralised", has_fixed_air_conditioning=False, number_of_open_flues=0, number_of_closed_flues=0, number_of_boiler_flues=0, number_of_other_flues=0, number_of_extract_fans=0, number_of_passive_vents=0, number_of_flueless_gas_fires=0, pressure_test="No test", draught_lobby=False, ventilation_in_pcdf_database=False, ) class TestConservatories: def test_full_conservatories(self) -> None: result = PasHubRdSapSiteNotesExtractor( load_text_fixture() ).extract_conservatories() assert result == Conservatories(has_conservatory=False) class TestRenewables: def test_number_of_pv_batteries_none_string_becomes_zero(self) -> None: result = PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_renewables() assert result.number_of_pv_batteries == 0 def test_full_renewables(self) -> None: result = PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_renewables() assert result == Renewables( wind_turbines=False, solar_hot_water=False, photovoltaic_array=False, number_of_pv_batteries=0, hydro=False, ) class TestRenewablesPvConnection: @pytest.fixture def renewables(self) -> Renewables: return PasHubRdSapSiteNotesExtractor( load_text_fixture_3() ).extract_renewables() def test_pv_connection(self, renewables: Renewables) -> None: assert renewables.pv_connection == "Connected to dwellings electricity meter" def test_percent_roof_covered_pv(self, renewables: Renewables) -> None: assert renewables.percent_roof_covered_pv == 45 class TestRenewablesPvArrays: """When "Photovoltaic array kWp Known?" is Yes, the survey lodges a per-system block ("Number of PV systems?", then "PV {n}: kWp value / Photovoltaic Pitch / Orientation / Overshading") instead of the percent-roof estimate. The extractor must capture it — dropped detail silently zeroes the Appendix M PV credit (issue #1590 bug 1; verbatim lines from accuracy fixture deal 507639151843). """ # The real kWp-known block, verbatim from the 507639151843 site notes. _KWP_KNOWN_BLOCK = [ "Photovoltaic array kWp Known?", "Yes", "Number of PV systems?", "1", "PV 1: kWp value:", "2.43 kWp", "PV 1: Photovoltaic Pitch?", "30 Degrees", "PV 1: Photovoltaic Orientation?", "South East", "PV 1: Photovoltaic Overshading:", "None or very little", ] @pytest.fixture def renewables(self) -> Renewables: # Arrange — fixture 3's Renewables section with its "kWp Known? No + # percent roof" block swapped for the real kWp-known block. lines = load_text_fixture_3() start = lines.index("Photovoltaic array kWp Known?") end = lines.index("Number of PV batteries:") spliced = lines[:start] + self._KWP_KNOWN_BLOCK + lines[end:] return PasHubRdSapSiteNotesExtractor(spliced).extract_renewables() def test_pv_array_detail_is_captured(self, renewables: Renewables) -> None: assert renewables.pv_arrays == [ PvArrayDetail( kwp=2.43, pitch_degrees=30, orientation="South East", overshading="None or very little", ) ] def test_pv_diverter_is_captured(self, renewables: Renewables) -> None: assert renewables.pv_diverter_present is False # fixture 3 lodges "No" def test_no_pv_block_leaves_arrays_absent(self) -> None: # Arrange / Act — fixture 3 as lodged (kWp Known? No, percent path). renewables = PasHubRdSapSiteNotesExtractor( load_text_fixture_3() ).extract_renewables() # Assert assert renewables.pv_arrays is None class TestMainHeatingCommunityHeatSource: """A community-heating dwelling lodges its heat source under "Heating System (Other):" (e.g. "Community heating - boilers only", verbatim from accuracy fixture deal 507644414148). Dropped, the mapper cannot lodge the Table 4a heat-network SAP code and the dwelling is priced as an ordinary boiler (issue #1590 follow-up). """ def test_heat_source_label_is_captured(self) -> None: # Arrange — fixture 1's heating section with the community block # spliced in after its "Fuel:" lodgement. lines = load_text_fixture() idx = lines.index("System type:") spliced = ( lines[: idx + 2] + ["Heating System (Other):", "Community heating - boilers only"] + lines[idx + 2 :] ) # Act mh = PasHubRdSapSiteNotesExtractor( spliced ).extract_heating_and_hot_water().main_heating # Assert assert mh.community_heat_source == "Community heating - boilers only" def test_absent_label_stays_blank(self) -> None: # Arrange / Act — fixture 1 as lodged (no community block). mh = PasHubRdSapSiteNotesExtractor( load_text_fixture() ).extract_heating_and_hot_water().main_heating # Assert assert mh.community_heat_source == "" class TestRoofSpaceRoomInRoof: """"Are there rooms in the roof? Yes" lodges an RdSAP §3.10 detailed RIR block (age, floor area, gables/slopes/common walls/flat ceiling as length × height). The extractor must capture it — dropped, ~32 m² of RIR surfaces are billed as well-insulated loft (issue #1590 bug 5; lines verbatim from accuracy fixture deal 499617935574, incl. the mid-block "Page 7" break). """ _RIR_BLOCK = [ "Are there rooms in the roof?", "Yes", "Roof room age range:", "B: 1900 - 1929", "Floor area of room in roof:", "32.35 m²", "Are details of the room in roof known?", "Yes", "Are details of the gable walls known?", "Gable wall 1 & 2", "Gable wall 1 length:", "6.64 m", "Gable wall 1 height:", "2.74 m", "Page 7", "Gable wall 1 U-Value:", "Unknown", "Gable wall 1 type:", "Exposed", "Gable wall 2 length:", "6.64 m", "Gable wall 2 height:", "2.74 m", "Gable wall 2 type:", "Party", "Are details of the stud walls known?", "None", "Are details of the slope walls known?", "Slope 1 & 2", "Slope 1 length:", "4.94 m", "Slope 1 height:", "1.73 m", "Slope 2 length:", "5 m", "Slope 2 height:", "2.95 m", "Are details of the common walls known?", "Common wall 1 & 2", "Common wall 1 length:", "4.94 m", "Common wall 1 height:", "1.45 m", "Common wall 2 length:", "4.94 m", "Common wall 2 height:", "1.3 m", "Are details of the flat ceiling known?", "Flat ceiling 1", "Flat ceiling 1 length:", "4.94 m", "Flat ceiling 1 height:", "5.17 m", ] def test_room_in_roof_block_is_captured(self) -> None: # Arrange — fixture 1's roof space with the RIR block spliced in place # of its "rooms in the roof? No" lodging. lines = load_text_fixture() idx = lines.index("Are there rooms in the roof?") spliced = lines[:idx] + self._RIR_BLOCK + lines[idx + 2 :] # Act detail = PasHubRdSapSiteNotesExtractor( spliced ).extract_roof_space().main_building # Assert assert detail.rooms_in_roof is True rir = detail.room_in_roof assert rir is not None assert rir.age_range == "B: 1900 - 1929" assert rir.floor_area_m2 == 32.35 assert rir.gables == [ RoomInRoofSurfaceDetail(length_m=6.64, height_m=2.74, gable_type="Exposed"), RoomInRoofSurfaceDetail(length_m=6.64, height_m=2.74, gable_type="Party"), ] assert rir.slopes == [ RoomInRoofSurfaceDetail(length_m=4.94, height_m=1.73), RoomInRoofSurfaceDetail(length_m=5.0, height_m=2.95), ] assert rir.common_walls == [ RoomInRoofSurfaceDetail(length_m=4.94, height_m=1.45), RoomInRoofSurfaceDetail(length_m=4.94, height_m=1.3), ] assert rir.flat_ceilings == [ RoomInRoofSurfaceDetail(length_m=4.94, height_m=5.17), ] # The same survey's RIR block in full, INCLUDING the per-surface # "{surface}: Are you able to provide details of the insulation …?" # questions (the question text wraps onto a second PDF line; the Yes/No # sits two tokens after the "{prefix}: Are you able …" line). Verbatim # from accuracy fixture deal 499617935574 tokens — the dwelling whose # loft hatch was screwed shut, so every roof-going surface answers "No". _RIR_BLOCK_WITH_INSULATION_QUESTIONS = [ "Are there rooms in the roof?", "Yes", "Roof room age range:", "B: 1900 - 1929", "Floor area of room in roof:", "32.35 m²", "Are details of the room in roof known?", "Yes", "Are details of the gable walls known?", "Gable wall 1 & 2", "Gable wall 1 length:", "6.64 m", "Gable wall 1 height:", "2.74 m", "Page 7", "", "Gable wall 1 U-Value:", "Unknown", "Gable wall 1: Are you able to provide details of the", "insulation type?", "Yes", "Gable wall 1 type:", "Exposed", "Gable wall 2 length:", "6.64 m", "Gable wall 2 height:", "2.74 m", "Gable wall 2 U-Value:", "Unknown", "Gable wall 2: Are you able to provide details of the", "insulation type?", "Yes", "Gable wall 2 type:", "Party", "Are details of the stud walls known?", "None", "Are details of the slope walls known?", "Slope 1 & 2", "Slope 1 length:", "4.94 m", "Slope 1 height:", "1.73 m", "Slope 1 U-Value:", "Unknown", "Slope 1: Are you able to provide details of the", "insulation type and thickness?", "No", "Slope 2 length:", "5 m", "Slope 2 height:", "2.95 m", "Slope 2 U-Value:", "Unknown", "Slope 2: Are you able to provide details of the", "insulation type and thickness?", "No", "Are details of the common walls known?", "Common wall 1 & 2", "Common wall 1 length:", "4.94 m", "Common wall 1 height:", "1.45 m", "Common wall 1 U-Value:", "Unknown", "Common wall 2 length:", "4.94 m", "Common wall 2 height:", "1.3 m", "Common wall 2 U-Value:", "Unknown", "Are details of the flat ceiling known?", "Flat ceiling 1", "Flat ceiling 1 length:", "4.94 m", "Flat ceiling 1 height:", "5.17 m", "Flat ceiling 1 U-Value:", "Unknown", "Flat ceiling 1: Are you able to provide details of the", "insulation thickness, type or location?", "No", ] def test_per_surface_insulation_known_answers_are_captured(self) -> None: # Arrange — fixture 1's roof space with the question-bearing RIR # block spliced in place of its "rooms in the roof? No" lodging. lines = load_text_fixture() idx = lines.index("Are there rooms in the roof?") spliced = ( lines[:idx] + self._RIR_BLOCK_WITH_INSULATION_QUESTIONS + lines[idx + 2 :] ) # Act rir = ( PasHubRdSapSiteNotesExtractor(spliced) .extract_roof_space() .main_building.room_in_roof ) # Assert — gables answered Yes, slopes/flat ceiling answered No; # common walls are never asked, so stay None (unknown). assert rir is not None assert rir.gables is not None assert [g.insulation_known for g in rir.gables] == [True, True] assert rir.slopes is not None assert [s.insulation_known for s in rir.slopes] == [False, False] assert rir.common_walls is not None assert [c.insulation_known for c in rir.common_walls] == [None, None] assert rir.flat_ceilings is not None assert [f.insulation_known for f in rir.flat_ceilings] == [False] # The Simplified-approach RIR block ("Are details of the room in roof # known? No" → "Roof room type: RR type 2"), gables + common walls only, # no slopes/flat ceilings. Verbatim from accuracy fixture 499538003156 # (78 North Road), including the mid-block "Page 8" break and the noise # lines the surveyor tool interleaves between a key and its value. _RIR_SIMPLIFIED_TYPE2_BLOCK = [ "Are there rooms in the roof?", "Yes", "Roof room age range:", "C: 1930 - 1949", "Floor area of room in roof:", "25.43 m²", "Are details of the room in roof known?", "No", "Roof room type:", "RR type 2 (With the Accessible areas of continuous common walls)", "Gable wall 1 length:", "5.82 m", "Gable wall 1 height:", "2.72 m", "Gable wall 1 type:", "Party", "Gable wall 2 length:", "5.82 m", "Gable wall 2 height:", "2.72 m", "Gable wall 2 type:", "Party", "Common wall 1 length:", "4.37 m", "Page 8", "", "Common wall 1 height:", "2.34 m", "Common wall 2 length:", "4.37 m", "Common wall 2 height:", "2.34 m", ] def test_roof_room_type_label_is_captured(self) -> None: # Arrange — fixture 1's roof space with the Simplified RIR block # spliced in place of its "rooms in the roof? No" lodging. lines = load_text_fixture() idx = lines.index("Are there rooms in the roof?") spliced = lines[:idx] + self._RIR_SIMPLIFIED_TYPE2_BLOCK + lines[idx + 2 :] # Act rir = ( PasHubRdSapSiteNotesExtractor(spliced) .extract_roof_space() .main_building.room_in_roof ) # Assert assert rir is not None assert ( rir.roof_room_type == "RR type 2 (With the Accessible areas of continuous common walls)" ) # Simplified lodgement: gables + common walls, no roof-going surfaces. assert rir.slopes is None assert rir.flat_ceilings is None assert rir.common_walls == [ RoomInRoofSurfaceDetail(length_m=4.37, height_m=2.34), RoomInRoofSurfaceDetail(length_m=4.37, height_m=2.34), ] def test_detailed_lodgement_has_no_roof_room_type(self) -> None: # Arrange — the Detailed §3.10 block (slopes/flat ceilings lodged) # never carries a "Roof room type:" line. lines = load_text_fixture() idx = lines.index("Are there rooms in the roof?") spliced = lines[:idx] + self._RIR_BLOCK + lines[idx + 2 :] # Act rir = ( PasHubRdSapSiteNotesExtractor(spliced) .extract_roof_space() .main_building.room_in_roof ) # Assert assert rir is not None assert rir.roof_room_type is None def test_no_rooms_in_roof_leaves_detail_absent(self) -> None: # Arrange / Act — fixture 1 as lodged ("No"). detail = PasHubRdSapSiteNotesExtractor( load_text_fixture() ).extract_roof_space().main_building # Assert assert detail.rooms_in_roof is False assert detail.room_in_roof is None class TestRoomCountElements: @pytest.fixture def rce(self) -> RoomCountElements: return PasHubRdSapSiteNotesExtractor( load_text_fixture() ).extract_room_count_elements() def test_habitable_rooms(self, rce: RoomCountElements) -> None: assert rce.number_of_habitable_rooms == 3 def test_heated_rooms_null(self, rce: RoomCountElements) -> None: assert rce.number_of_heated_rooms is None def test_full_room_count_elements(self, rce: RoomCountElements) -> None: assert rce == RoomCountElements( number_of_habitable_rooms=3, any_unheated_rooms=False, number_of_heated_rooms=None, number_of_external_doors=2, number_of_insulated_external_doors=0, number_of_draughtproofed_external_doors=2, number_of_open_chimneys=0, number_of_blocked_chimneys=0, number_of_fixed_incandescent_bulbs=4, exact_led_cfl_count_known=True, number_of_fixed_led_bulbs=0, number_of_fixed_cfl_bulbs=1, waste_water_heat_recovery="None", ) class TestWaterUse: def test_full_water_use(self) -> None: result = PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_water_use() assert result == WaterUse( number_of_baths=1, number_of_special_features=0, showers=[Shower(id=1, outlet_type="Non-Electric Shower")], ) class TestCustomerResponse: def test_full_customer_response(self) -> None: result = PasHubRdSapSiteNotesExtractor( load_text_fixture() ).extract_customer_response() assert result == CustomerResponse( customer_present=True, willing_to_answer_satisfaction_survey=False, ) class TestExtract: def test_full_extract(self) -> None: result = PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract() assert result.inspection_metadata.inspection_surveyor == "Benjamin Burke" assert result.general.inspection_date == date(2025, 9, 25) assert result.building_construction.main_building.wall_thickness_mm == 310 assert result.building_measurements.main_building.floors[0].area_m2 == 35.68 assert result.roof_space.main_building.insulation_thickness_mm == 100 assert len(result.windows) == 8 assert result.heating_and_hot_water.main_heating.product_id == 16839 assert result.ventilation.ventilation_type == "Mechanical Extract - Decentralised" assert result.conservatories.has_conservatory is False assert result.renewables.number_of_pv_batteries == 0 assert result.room_count_elements.number_of_habitable_rooms == 3 assert result.water_use.number_of_baths == 1 assert result.customer_response.customer_present is True assert result.addendum.addendum == "None" class TestSurveyAddendum: def test_hard_to_treat_flags(self) -> None: result = PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_addendum() assert result.hard_to_treat_cavity_access_issues is False assert result.hard_to_treat_cavity_high_exposure is False assert result.hard_to_treat_cavity_narrow_cavities is False def test_full_addendum(self) -> None: result = PasHubRdSapSiteNotesExtractor(load_text_fixture()).extract_addendum() assert result == SurveyAddendum( addendum="None", related_party_disclosure="No related party", hard_to_treat_cavity_access_issues=False, hard_to_treat_cavity_high_exposure=False, hard_to_treat_cavity_narrow_cavities=False, ) # --- fixture 4: heat pump, factory-fitted cylinder, blocked loft --- class TestCylinderInsulationType: @pytest.fixture def hhw(self) -> HeatingAndHotWater: return PasHubRdSapSiteNotesExtractor( load_text_fixture_4() ).extract_heating_and_hot_water() def test_insulation_type_extracted(self, hhw: HeatingAndHotWater) -> None: assert hhw.water_heating.insulation_type == "Factory fitted" def test_insulation_thickness_mm(self, hhw: HeatingAndHotWater) -> None: assert hhw.water_heating.insulation_thickness_mm == 50 def test_cylinder_size(self, hhw: HeatingAndHotWater) -> None: assert hhw.water_heating.cylinder_size == "Medium (131-170 litres)" class TestHeatPumpFuelExtraction: @pytest.fixture def hhw(self) -> HeatingAndHotWater: return PasHubRdSapSiteNotesExtractor( load_text_fixture_4() ).extract_heating_and_hot_water() def test_fuel_raw_value(self, hhw: HeatingAndHotWater) -> None: assert hhw.main_heating.fuel == "Electricity, any tariff" def test_system_type(self, hhw: HeatingAndHotWater) -> None: assert hhw.main_heating.system_type == "Heat pump with radiators or underfloor heating" class TestRoofSpaceUnknownInsulation: @pytest.fixture def roof_space(self) -> RoofSpace: return PasHubRdSapSiteNotesExtractor( load_text_fixture_4() ).extract_roof_space() def test_insulation_at_unknown(self, roof_space: RoofSpace) -> None: assert roof_space.main_building.insulation_at == "Unknown" def test_insulation_thickness_mm_none(self, roof_space: RoofSpace) -> None: assert roof_space.main_building.insulation_thickness_mm is None def test_insulation_thickness_str_none(self, roof_space: RoofSpace) -> None: assert roof_space.main_building.insulation_thickness is None class TestCflBulbCount: @pytest.fixture def rce(self) -> RoomCountElements: return PasHubRdSapSiteNotesExtractor( load_text_fixture_5() ).extract_room_count_elements() def test_cfl_count(self, rce: RoomCountElements) -> None: assert rce.number_of_fixed_cfl_bulbs == 2 def test_led_count(self, rce: RoomCountElements) -> None: assert rce.number_of_fixed_led_bulbs == 7 def test_incandescent_count(self, rce: RoomCountElements) -> None: assert rce.number_of_fixed_incandescent_bulbs == 1 class TestSecondaryHeatingPanel: @pytest.fixture def hhw(self) -> HeatingAndHotWater: return PasHubRdSapSiteNotesExtractor( load_text_fixture_5() ).extract_heating_and_hot_water() def test_secondary_system(self, hhw: HeatingAndHotWater) -> None: assert hhw.secondary_heating.secondary_system == "Panel, convector or radiant heaters" def test_secondary_fuel(self, hhw: HeatingAndHotWater) -> None: assert hhw.secondary_heating.secondary_fuel == "Electricity" class TestElectricShowerExtraction: @pytest.fixture def wu(self) -> WaterUse: return PasHubRdSapSiteNotesExtractor( load_text_fixture_5() ).extract_water_use() def test_shower_outlet_type(self, wu: WaterUse) -> None: assert wu.showers[0].outlet_type == "Electric Shower" # --- fixture 7: maisonette, 2 extensions, no property photo --- class TestExtractNoPropertyPhoto: def test_address_extracted_when_no_property_photo(self) -> None: result = PasHubRdSapSiteNotesExtractor(load_text_fixture_7()).extract() assert result.inspection_metadata.property_address == "Flat 3, 29 Watcombe Circus, NOTTINGHAM, NG5 2DU" assert result.inspection_metadata.property_photo is False assert result.general.property_type == "Maisonette" assert result.general.number_of_extensions == 2 class TestWallThicknessExtraction: def _extractor(self) -> PasHubRdSapSiteNotesExtractor: return PasHubRdSapSiteNotesExtractor([]) def test_numeric_value_returns_int(self) -> None: assert self._extractor()._wall_thickness_in(["Wall thickness:", "310 mm"]) == 310 def test_unmeasurable_returns_none(self) -> None: assert self._extractor()._wall_thickness_in(["Wall thickness:", "Unmeasurable"]) is None def test_unmeasurable_lowercase_returns_none(self) -> None: assert self._extractor()._wall_thickness_in(["Wall thickness:", "unmeasurable"]) is None def test_unmeasurable_uppercase_returns_none(self) -> None: assert self._extractor()._wall_thickness_in(["Wall thickness:", "UNMEASURABLE"]) is None def test_missing_field_returns_none(self) -> None: assert self._extractor()._wall_thickness_in([]) is None class TestSolidMasonryPartyWall: @pytest.fixture def bc(self) -> BuildingConstruction: return PasHubRdSapSiteNotesExtractor( load_text_fixture_6() ).extract_building_construction() def test_party_wall_construction_type(self, bc: BuildingConstruction) -> None: assert ( bc.main_building.party_wall_construction_type == "Solid Masonry, Timber Frame, or System Built" )