"""Persistence round-trip fidelity for EPC Property Data (Slice 1, #1129). The load-bearing risk of the ara_first_run rebuild: an EpcPropertyData mapped to the epc_property tables, saved, reloaded and mapped back must reconstruct the original object exactly. A failure here is either a missing column (a migration the FE repo must make) or a mapper gap — either way we want it to fail loudly, inside First Run, rather than be deferred to a later Refresh. """ from __future__ import annotations import json from pathlib import Path from typing import Any import pytest from sqlalchemy import Engine from sqlmodel import Session from datatypes.epc.domain.epc_property_data import EpcPropertyData from datatypes.epc.domain.mapper import EpcPropertyDataMapper from repositories.epc.epc_postgres_repository import EpcPostgresRepository _JSON_SAMPLES = Path(__file__).resolve().parents[3] / "backend/epc_api/json_samples" def _load_epc(schema_dir: str) -> EpcPropertyData: raw: dict[str, Any] = json.loads( (_JSON_SAMPLES / schema_dir / "epc.json").read_text() ) return EpcPropertyDataMapper.from_api_response(raw) @pytest.mark.parametrize( "schema_dir", ["RdSAP-Schema-21.0.0", "RdSAP-Schema-21.0.1"], ) def test_epc_property_data_round_trips(schema_dir: str, db_engine: Engine) -> None: # Arrange original = _load_epc(schema_dir) # Act with Session(db_engine) as session: epc_property_id = EpcPostgresRepository(session).save(original) session.commit() with Session(db_engine) as session: reloaded = EpcPostgresRepository(session).get(epc_property_id) # Assert assert reloaded == original def test_non_separated_conservatory_round_trips(db_engine: Engine) -> None: # RdSAP 10 §6.1 — a non-separated conservatory (conservatory_type=4) maps to # `EpcPropertyData.sap_conservatory` (the glazed BP is split out of the # fabric parts). Persistence must round-trip it: scoring reads the reloaded # picture, so a dropped `sap_conservatory` silently disregards the # conservatory (persist != score). We inject the conservatory onto the # known-clean 21.0.1 sample so the ONLY thing that can break deep-equality # is `sap_conservatory` itself. # Arrange — known-clean base + a non-separated double-glazed conservatory. raw: dict[str, Any] = json.loads( (_JSON_SAMPLES / "RdSAP-Schema-21.0.1" / "epc.json").read_text() ) raw["conservatory_type"] = 4 raw["sap_building_parts"].append( { "floor_area": 12.0, "room_height": 1, "double_glazed": "Y", "glazed_perimeter": 9.0, } ) original = EpcPropertyDataMapper.from_api_response(raw) assert original.sap_conservatory is not None, "mapper must split the conservatory" # Act with Session(db_engine) as session: epc_property_id = EpcPostgresRepository(session).save(original) session.commit() with Session(db_engine) as session: reloaded = EpcPostgresRepository(session).get(epc_property_id) # Assert — the conservatory survives the round-trip, deep-equal. assert reloaded == original def test_photovoltaic_arrays_round_trip(db_engine: Engine) -> None: # SAP 10.2 Appendix M — a dwelling's solar PV arrays generate electricity that # offsets demand, worth a large slice of the SAP score (≈12 points on an # electrically-heated dwelling). `sap_energy_source.photovoltaic_arrays` had # no table, so every array was dropped on save (persist != score). We inject # two arrays (distinct, so ORDER is observable) onto a clean PV-free fixture # so the ONLY thing that can break deep-equality is the array list itself. from dataclasses import replace from datatypes.epc.domain.epc_property_data import PhotovoltaicArray # Arrange — a green fixture with no PV, plus two ordered arrays. original = _load_epc("RdSAP-Schema-21.0.1") assert original.sap_energy_source.photovoltaic_arrays is None, ( "fixture must start PV-free so the array list is the only variable" ) arrays = [ PhotovoltaicArray(peak_power=3.24, pitch=3, overshading=1, orientation=3), PhotovoltaicArray(peak_power=1.5, pitch=2, overshading=2, orientation=None), ] original = replace( original, sap_energy_source=replace( original.sap_energy_source, photovoltaic_arrays=arrays ), ) # Act with Session(db_engine) as session: epc_property_id = EpcPostgresRepository(session).save(original) session.commit() with Session(db_engine) as session: reloaded = EpcPostgresRepository(session).get(epc_property_id) # Assert — both arrays survive in order, deep-equal. assert reloaded == original def test_calculator_read_fields_round_trip(db_engine: Engine) -> None: # Seven fields the SAP calculator reads (cert_to_inputs / heat_transmission) # that had no DB column, so they were silently dropped on save (persist != # score). FE columns now exist; inject all seven onto a clean fixture and # prove they survive the round-trip deep-equal. from dataclasses import replace # Arrange original = _load_epc("RdSAP-Schema-21.0.1") bp0 = original.sap_building_parts[0] assert bp0.sap_alternative_wall_1 is not None, "fixture must carry an alt wall" bp0 = replace( bp0, sap_alternative_wall_1=replace(bp0.sap_alternative_wall_1, is_sheltered=True), wall_insulation_thermal_conductivity=35, # Union[int,str] → JSONB int ) heating = original.sap_heating mh0 = replace( heating.main_heating_details[0], community_heating_boiler_fuel_type=35, community_heating_chp_fraction=0.35, ) heating = replace( heating, main_heating_details=[mh0, *heating.main_heating_details[1:]], cylinder_volume_measured_l=180, ) original = replace( original, sap_building_parts=[bp0, *original.sap_building_parts[1:]], sap_heating=heating, sap_energy_source=replace(original.sap_energy_source, pv_diverter_present=True), sap_ventilation=replace( original.sap_ventilation, air_permeability_ap50_m3_h_m2=7.5 ), ) # Act with Session(db_engine) as session: epc_property_id = EpcPostgresRepository(session).save(original) session.commit() with Session(db_engine) as session: reloaded = EpcPostgresRepository(session).get(epc_property_id) # Assert — all seven calculator-read fields survive, deep-equal. assert reloaded == original def test_floor_dimension_heat_loss_flags_round_trip(db_engine: Engine) -> None: # SAP 10.2 §3.3 — `is_exposed_floor` and `is_above_partially_heated_space` # are heat-loss flags on a `SapFloorDimension`: the calculator routes a floor # over outside air / over a partially-heated space through a different # U-value path. They had no `epc_floor_dimension` column, so a True flag # round-tripped back to the `False` default and silently flipped the floor's # heat loss (persist != score). We force both True on a clean fixture so the # ONLY thing that can break deep-equality is these two flags. from dataclasses import replace # Arrange — a green fixture with a floor dimension, both flags forced True. original = _load_epc("RdSAP-Schema-21.0.0") assert original.sap_building_parts, "fixture must have a building part" bp0 = original.sap_building_parts[0] assert bp0.sap_floor_dimensions, "fixture building part must have a floor dimension" dim0 = replace( bp0.sap_floor_dimensions[0], is_exposed_floor=True, is_above_partially_heated_space=True, ) bp0 = replace(bp0, sap_floor_dimensions=[dim0, *bp0.sap_floor_dimensions[1:]]) original = replace( original, sap_building_parts=[bp0, *original.sap_building_parts[1:]] ) # Act with Session(db_engine) as session: epc_property_id = EpcPostgresRepository(session).save(original) session.commit() with Session(db_engine) as session: reloaded = EpcPostgresRepository(session).get(epc_property_id) # Assert — both flags survive the round-trip, deep-equal. assert reloaded == original def test_building_part_wall_insulation_thickness_preserves_int( db_engine: Engine, ) -> None: # SAP 10.2 §5.7/Table 8: when the API lodges # `wall_insulation_thickness == "measured"`, the mapper resolves the # value to an int mm. The `epc_building_part.wall_insulation_thickness` # column must therefore preserve int vs str on round-trip (JSONB), like # its `roof_insulation_thickness` sibling — a plain str column would # round-trip the int 100 back as "100" and corrupt the Table 8 lookup. from dataclasses import replace # Arrange — take a green fixture and force the measured-int case. original = _load_epc("RdSAP-Schema-21.0.0") assert original.sap_building_parts, "fixture must have a building part" bp0 = replace(original.sap_building_parts[0], wall_insulation_thickness=100) original = replace( original, sap_building_parts=[bp0, *original.sap_building_parts[1:]], ) # Act with Session(db_engine) as session: epc_property_id = EpcPostgresRepository(session).save(original) session.commit() with Session(db_engine) as session: reloaded = EpcPostgresRepository(session).get(epc_property_id) # Assert — the int survives as an int, not the string "100". assert reloaded is not None value = reloaded.sap_building_parts[0].wall_insulation_thickness assert value == 100 assert isinstance(value, int)