diff --git a/tests/orchestration/fakes.py b/tests/orchestration/fakes.py index 3e2feef0..06c22247 100644 --- a/tests/orchestration/fakes.py +++ b/tests/orchestration/fakes.py @@ -10,25 +10,51 @@ from types import TracebackType from typing import Any, Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.plan import Plan +from domain.modelling.scenario import Scenario from domain.property_baseline.property_baseline_performance import PropertyBaselinePerformance from domain.property.properties import Properties from domain.property.property import Property +from repositories.plan.plan_repository import PlanRepository +from repositories.product.product_repository import ProductRepository from repositories.property_baseline.property_baseline_repository import PropertyBaselineRepository from repositories.epc.epc_repository import EpcRepository from repositories.property.property_repository import PropertyRepository +from repositories.scenario.scenario_repository import ScenarioRepository from repositories.solar.solar_repository import SolarRepository from repositories.unit_of_work import UnitOfWork +from domain.modelling.product import Product class FakePropertyRepo(PropertyRepository): - def __init__(self, by_id: dict[int, Property]) -> None: + """Holds Properties by id. When an ``epc_repo`` is supplied it composes the + effective EPC from it at read time — mirroring `PropertyPostgresRepository`, + so an EPC that Ingestion persists becomes visible on the Property that + Baseline / Modelling read back (the through-repos hand-off, in memory).""" + + def __init__( + self, + by_id: dict[int, Property], + epc_repo: Optional["FakeEpcRepo"] = None, + ) -> None: self._by_id = by_id + self._epc_repo = epc_repo + + def _hydrate(self, property_id: int) -> Property: + prop = self._by_id[property_id] + if self._epc_repo is None: + return prop + return Property( + identity=prop.identity, + epc=self._epc_repo.get_for_property(property_id), + site_notes=prop.site_notes, + ) def get(self, property_id: int) -> Property: - return self._by_id[property_id] + return self._hydrate(property_id) def get_many(self, property_ids: list[int]) -> Properties: - return Properties([self._by_id[property_id] for property_id in property_ids]) + return Properties([self._hydrate(property_id) for property_id in property_ids]) class FakeEpcRepo(EpcRepository): @@ -88,6 +114,52 @@ class FakePropertyBaselineRepo(PropertyBaselineRepository): raise NotImplementedError +class FakeScenarioRepository(ScenarioRepository): + def __init__(self, by_id: Optional[dict[int, Scenario]] = None) -> None: + self._by_id = by_id or {} + + def get_many(self, scenario_ids: list[int]) -> list[Scenario]: + missing = [sid for sid in scenario_ids if sid not in self._by_id] + if missing: + raise ValueError(f"no scenario for ids {missing}") + return [self._by_id[sid] for sid in scenario_ids] + + +class FakePlanRepository(PlanRepository): + """Idempotent in-memory Plan store keyed by ``(property_id, scenario_id)`` — + a re-run replaces rather than duplicates (ADR-0017). ``saved`` is the store + a test (or the console harness) reads the Plan back from.""" + + def __init__(self) -> None: + self.saved: dict[tuple[int, int], Plan] = {} + self._next_id = 1 + + def save( + self, + plan: Plan, + *, + property_id: int, + scenario_id: int, + portfolio_id: int, + is_default: bool, + ) -> int: + self.saved[(property_id, scenario_id)] = plan + plan_id = self._next_id + self._next_id += 1 + return plan_id + + +class _UnsetProductRepo(ProductRepository): + """Default for a `FakeUnitOfWork` built without a catalogue — raises if a + generator actually reaches for a Product, so the omission is loud.""" + + def get(self, measure_type: str) -> Product: # pragma: no cover + raise ValueError( + f"no product catalogue wired into this FakeUnitOfWork " + f"(asked for {measure_type!r})" + ) + + class FakeUnitOfWork(UnitOfWork): """A unit that holds in-memory repos and counts commits.""" @@ -98,11 +170,17 @@ class FakeUnitOfWork(UnitOfWork): epc: Optional[FakeEpcRepo] = None, solar: Optional[FakeSolarRepo] = None, property_baseline: Optional[FakePropertyBaselineRepo] = None, + scenario: Optional[FakeScenarioRepository] = None, + product: Optional[ProductRepository] = None, + plan: Optional[FakePlanRepository] = None, ) -> None: self.property = property self.epc = epc or FakeEpcRepo() self.solar = solar or FakeSolarRepo() self.property_baseline = property_baseline or FakePropertyBaselineRepo() + self.scenario = scenario or FakeScenarioRepository() + self.product = product or _UnsetProductRepo() + self.plan = plan or FakePlanRepository() self.commits = 0 def __enter__(self) -> "FakeUnitOfWork": diff --git a/tests/orchestration/fixtures/product_catalogue.json b/tests/orchestration/fixtures/product_catalogue.json new file mode 100644 index 00000000..ab006317 --- /dev/null +++ b/tests/orchestration/fixtures/product_catalogue.json @@ -0,0 +1,5 @@ +{ + "cavity_wall_insulation": { "unit_cost_per_m2": 18.5 }, + "suspended_floor_insulation": { "unit_cost_per_m2": 25.0 }, + "mechanical_ventilation": { "unit_cost_per_m2": 450.0 } +} diff --git a/tests/orchestration/test_first_run_without_database.py b/tests/orchestration/test_first_run_without_database.py new file mode 100644 index 00000000..ea7b5a81 --- /dev/null +++ b/tests/orchestration/test_first_run_without_database.py @@ -0,0 +1,151 @@ +"""First Run end-to-end with NO database — in-memory fakes only. + +The same `AraFirstRunPipeline` the Postgres integration test drives, but wired +against a `FakeUnitOfWork` instead of a `PostgresUnitOfWork`: Ingestion -> +Baseline -> Modelling run start-to-finish, hand off through in-memory repos, and +produce an inspectable multi-measure Plan without a `Session` ever being opened. +This is the harness the owner runs to sense-check recommendations interactively. +""" + +from __future__ import annotations + +import dataclasses +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional + +from datatypes.epc.domain.epc import Epc +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.scenario import Scenario +from domain.property.property import Property, PropertyIdentity +from domain.property_baseline.rebaseliner import StubRebaseliner +from domain.sap10_calculator.calculator import Sap10Calculator +from orchestration.ara_first_run_pipeline import AraFirstRunPipeline +from orchestration.ingestion_orchestrator import IngestionOrchestrator +from orchestration.modelling_orchestrator import ModellingOrchestrator +from orchestration.property_baseline_orchestrator import PropertyBaselineOrchestrator +from repositories.fuel_rates.fuel_rates_static_file_repository import ( + FuelRatesStaticFileRepository, +) +from repositories.geospatial.geospatial_repository import GeospatialRepository +from repositories.product.product_json_repository import ProductJsonRepository +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc as _build_uninsulated_cavity_and_floor_epc, +) +from tests.orchestration.fakes import ( + FakeEpcRepo, + FakePlanRepository, + FakePropertyRepo, + FakeScenarioRepository, + FakeUnitOfWork, +) + +_CATALOGUE = Path(__file__).resolve().parent / "fixtures/product_catalogue.json" + + +@dataclass +class _Command: + portfolio_id: int + property_ids: list[int] + scenario_ids: list[int] + + +class _FetcherReturning: + def __init__(self, epc: EpcPropertyData) -> None: + self._epc = epc + + def get_by_uprn(self, uprn: int) -> Optional[EpcPropertyData]: + return self._epc + + +class _NoCoordinates(GeospatialRepository): + def coordinates_for(self, uprn: int): # type: ignore[no-untyped-def] + return None # skip the solar leg + + +class _UnusedSolarFetcher: + def get_building_insights( + self, longitude: float, latitude: float + ) -> dict[str, Any]: # pragma: no cover + return {} + + +def _uninsulated_lodged_epc() -> EpcPropertyData: + # 000490: an uninsulated cavity wall + suspended floor (loft already 300mm), + # so the wall + floor Generators fire and the ventilation Dependency follows. + # The calculator fixture carries no lodged recorded-performance, so we fill it + # in (as a real lodged EPC would) — it already carries the RHI block — so the + # Baseline stage can run inside the full pipeline. + epc = _build_uninsulated_cavity_and_floor_epc() + return dataclasses.replace( + epc, + energy_rating_current=57, + current_energy_efficiency_band=Epc.D, + co2_emissions_current=3.0, + energy_consumption_current=300, + ) + + +def test_first_run_produces_a_multi_measure_plan_without_a_database() -> None: + # Arrange — an in-memory Property (no EPC yet; Ingestion supplies it), a + # default Increasing-EPC Scenario, and a file-backed product catalogue. + epc_repo = FakeEpcRepo() + plan_repo = FakePlanRepository() + property_repo = FakePropertyRepo( + { + 10: Property( + identity=PropertyIdentity( + portfolio_id=1, + postcode="A0 0AA", + address="1 Some Street", + uprn=12345, + ) + ) + }, + epc_repo=epc_repo, + ) + unit: FakeUnitOfWork = FakeUnitOfWork( + property=property_repo, + epc=epc_repo, + scenario=FakeScenarioRepository( + { + 7: Scenario( + id=7, + goal="Increasing EPC", + goal_value="C", + budget=None, + is_default=True, + ) + } + ), + product=ProductJsonRepository(_CATALOGUE), + plan=plan_repo, + ) + + pipeline = AraFirstRunPipeline( + ingestion=IngestionOrchestrator( + unit_of_work=lambda: unit, + epc_fetcher=_FetcherReturning(_uninsulated_lodged_epc()), + geospatial_repo=_NoCoordinates(), + solar_fetcher=_UnusedSolarFetcher(), + ), + baseline=PropertyBaselineOrchestrator( + unit_of_work=lambda: unit, + rebaseliner=StubRebaseliner(), + fuel_rates=FuelRatesStaticFileRepository(), + ), + modelling=ModellingOrchestrator( + unit_of_work=lambda: unit, + calculator=Sap10Calculator(), + fuel_rates=FuelRatesStaticFileRepository(), + ), + ) + + # Act — the whole First Run, no Session ever opened. + pipeline.run(_Command(portfolio_id=1, property_ids=[10], scenario_ids=[7])) + + # Assert — a Plan was persisted in memory for (property 10, scenario 7), + # with at least one Plan Measure and a post-retrofit SAP no worse than baseline. + plan = plan_repo.saved[(10, 7)] + assert len(plan.measures) >= 1 + assert plan.post_sap_continuous >= plan.baseline.sap_continuous