Model/tests/repositories/epc/test_epc_round_trip.py
Khalim Conn-Kowlessar 5c4a8d9094 test: 7 calculator-read EPC fields must round-trip 🟥
community heating fuel + CHP fraction, alt-wall is_sheltered, wall
insulation thermal conductivity, pv_diverter_present, measured cylinder
volume, AP50 air permeability — all calculator-read, all silently dropped on
save. FE columns now live; assert deep-equal round-trip and drop their
coverage-guard allow-list entries so the guard enforces reconstruction.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:42:51 +00:00

242 lines
9.6 KiB
Python

"""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)