diff --git a/tests/domain/property_baseline/test_portfolio_796_override_rebaseline.py b/tests/domain/property_baseline/test_portfolio_796_override_rebaseline.py new file mode 100644 index 00000000..618ed35f --- /dev/null +++ b/tests/domain/property_baseline/test_portfolio_796_override_rebaseline.py @@ -0,0 +1,192 @@ +"""Regression: portfolio-796 properties whose Landlord Overrides moved them off +their lodged headline must rebaseline off the calculator (not echo the lodged +figure). + +These seven properties (a hit-list Khalim raised) each displayed an Effective +baseline equal to their *lodged accredited* SAP — band B/C — while the modelling +plan modelled the override-applied picture (often a different band), producing an +incoherent "already B, post-works C/D" plan. The cause: the rebaseliner kept the +lodged figure for any SAP >= 10.2 cert, ignoring the overrides (and, for the +predicted ones, the synthetic headline). The fix: when a physical-state trigger +fired — Landlord Overrides or Prediction — the calculator output IS the Effective +baseline, so the displayed baseline and the plan agree. + +This test pins, per property, that (1) its real override set still resolves to +overlays and trips `physical_state_changed`, and (2) the rebaseliner adopts the +calculator output (reason physical_state_changed / both), not the lodged figure. +A representative end-to-end case scores a real cert through the live calculator. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +import pytest + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.property.property import Property, PropertyIdentity +from domain.property_baseline.calculator_rebaseliner import CalculatorRebaseliner +from domain.property_baseline.performance import lodged_performance +from domain.sap10_calculator.calculator import Sap10Calculator +from repositories.property.landlord_override_overlays import overlays_from +from repositories.property.property_overrides_reader import ( + ResolvedPropertyOverride, + ResolvedPropertyOverrides, +) + +_JSON_SAMPLES = Path(__file__).resolve().parents[3] / "backend/epc_api/json_samples" + + +def _cert() -> EpcPropertyData: + """A real, scorable lodged cert used as the base picture (and, for the + predicted cases, as a stand-in synthesised EPC).""" + raw: dict[str, Any] = json.loads( + (_JSON_SAMPLES / "RdSAP-Schema-21.0.0" / "epc.json").read_text() + ) + return EpcPropertyDataMapper.from_api_response(raw) + + +def _overrides(*pairs: tuple[str, str]) -> ResolvedPropertyOverrides: + return ResolvedPropertyOverrides( + rows=tuple( + ResolvedPropertyOverride( + override_component=component, building_part=0, override_value=value + ) + for component, value in pairs + ) + ) + + +@dataclass(frozen=True) +class _Case: + pid: int + source: Literal["lodged", "predicted"] + overrides: ResolvedPropertyOverrides + + +# The seven properties' real Landlord Override sets (as stored in +# property_overrides), captured verbatim from portfolio 796. +_CASES: tuple[_Case, ...] = ( + _Case(725634, "lodged", _overrides( + ("main_heating_system", "Electric storage heaters, slimline"), + ("wall_type", "Cavity wall, as built, no insulation (assumed)"), + ("main_fuel", "electricity"), + ("water_heating", "Electric immersion, electricity"), + ("glazing", "Double glazing, pre-2002"), + ("property_type", "House"), + ("built_form_type", "Mid-Terrace"), + ("construction_age_band", "E"), + )), + _Case(721534, "lodged", _overrides( + ("main_heating_system", "Gas boiler, combi"), + ("wall_type", "System built, as built, no insulation (assumed)"), + ("main_fuel", "mains gas"), + ("water_heating", "From main system, mains gas"), + ("glazing", "Double glazing, 2002 or later"), + ("property_type", "Flat"), + ("built_form_type", "Mid-Terrace"), + ("construction_age_band", "K"), + )), + _Case(721985, "predicted", _overrides( + ("main_heating_system", "Gas boiler, combi"), + ("wall_type", "Solid brick, as built, no insulation (assumed)"), + ("main_fuel", "mains gas"), + ("water_heating", "From main system, mains gas"), + ("glazing", "Double glazing, 2002 or later"), + ("property_type", "Maisonette"), + ("built_form_type", "Semi-Detached"), + ("construction_age_band", "B"), + )), + _Case(721993, "predicted", _overrides( + ("main_heating_system", "Gas boiler, combi"), + ("wall_type", "Solid brick, as built, no insulation (assumed)"), + ("main_fuel", "mains gas"), + ("water_heating", "From main system, mains gas"), + ("glazing", "Single glazing"), + ("property_type", "Maisonette"), + ("built_form_type", "Semi-Detached"), + ("construction_age_band", "B"), + )), + _Case(735096, "predicted", _overrides( + ("main_heating_system", "Gas boiler, regular"), + ("wall_type", "Cavity wall, as built, insulated (assumed)"), + ("main_fuel", "electricity"), + ("water_heating", "Electric immersion, electricity"), + ("glazing", "Double glazing, 2002 or later"), + ("property_type", "Flat"), + ("built_form_type", "Enclosed Mid-Terrace"), + ("construction_age_band", "K"), + )), + _Case(735220, "predicted", _overrides( + ("main_heating_system", "Gas boiler, regular"), + ("wall_type", "Cavity wall, as built, insulated (assumed)"), + ("main_fuel", "electricity"), + ("water_heating", "Electric immersion, electricity"), + ("glazing", "Double glazing, 2002 or later"), + ("property_type", "Flat"), + ("built_form_type", "Enclosed Mid-Terrace"), + ("construction_age_band", "J"), + )), + _Case(739060, "predicted", _overrides( + ("main_heating_system", "Gas CPSU"), + ("wall_type", "System built, as built, no insulation (assumed)"), + ("main_fuel", "mains gas (community)"), + ("water_heating", "From main system, mains gas"), + ("glazing", "Double glazing, pre-2002"), + ("property_type", "Flat"), + ("built_form_type", "Mid-Terrace"), + ("construction_age_band", "K"), + )), +) + + +def _property_for(case: _Case) -> Property: + overlays = overlays_from(case.overrides) + identity = PropertyIdentity( + portfolio_id=796, postcode="A0 0AA", address="", uprn=case.pid + ) + if case.source == "lodged": + return Property(identity=identity, epc=_cert(), landlord_overrides=overlays) + return Property( + identity=identity, epc=None, predicted_epc=_cert(), landlord_overrides=overlays + ) + + +@pytest.mark.parametrize("case", _CASES, ids=lambda c: str(c.pid)) +def test_override_set_resolves_and_trips_physical_state_changed(case: _Case) -> None: + # The stored override values must still resolve to overlays (a renamed enum + # value would silently stop overriding), and the resulting Property must trip + # the Rebaselining trigger (b)/(c) — the signal that stops the baseline + # echoing the lodged headline. + overlays = overlays_from(case.overrides) + assert overlays, f"property {case.pid}: no override resolved to an overlay" + assert _property_for(case).physical_state_changed is True + + +@pytest.mark.parametrize("case", _CASES, ids=lambda c: str(c.pid)) +def test_property_rebaselines_off_the_calculator_not_the_lodged_headline( + case: _Case, +) -> None: + # The load-bearing guarantee: with a physical-state change, the Effective + # baseline is the calculator's scoring of EPC + overrides — so the displayed + # baseline and the modelled plan derive from the same picture and agree. + prop = _property_for(case) + effective_epc = prop.effective_epc + lodged = lodged_performance(effective_epc) + rebaseliner = CalculatorRebaseliner(Sap10Calculator()) + + result = rebaseliner.rebaseline( + case.pid, + effective_epc, + lodged, + physical_state_changed=prop.physical_state_changed, + ) + + assert result.reason in ("physical_state_changed", "both") + assert result.sap_result is not None + # Effective is the calculator's output, not the lodged accredited headline. + assert result.effective.sap_score == result.sap_result.sap_score