"""In-memory fakes for orchestrator unit tests (no DB, no network). A `FakeUnitOfWork` exposes dict-backed fake repos and records commits, so a test can drive an orchestrator and then assert what was persisted and that the batch committed exactly once (ADR-0012).""" from __future__ import annotations from types import TracebackType from typing import Any, Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.geospatial.spatial_reference import SpatialReference 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, EpcSource from repositories.property.property_repository import ( PropertyIdentityInsert, PropertyRepository, ) from repositories.scenario.scenario_repository import ScenarioRepository from repositories.solar.solar_repository import SolarRepository from repositories.spatial.spatial_repository import SpatialRepository from repositories.unit_of_work import UnitOfWork from domain.modelling.product import Product class FakePropertyRepo(PropertyRepository): """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, current_market_value=prop.current_market_value, ) def get(self, property_id: int) -> Property: return self._hydrate(property_id) def get_many(self, property_ids: list[int]) -> Properties: return Properties([self._hydrate(property_id) for property_id in property_ids]) def mark_modelled(self, property_id: int, *, has_recommendations: bool) -> None: # Record the marker so tests can assert the pipeline set it. self.modelled: dict[int, bool] = getattr(self, "modelled", {}) self.modelled[property_id] = has_recommendations def insert_all(self, rows: list[PropertyIdentityInsert]) -> int: self.inserted: list[PropertyIdentityInsert] = list(rows) return len(rows) def ids_by_uprn(self, portfolio_id: int, uprns: list[int]) -> dict[int, int]: # Not exercised by the baseline/EPC orchestrator tests that use this fake. return {} class FakeEpcRepo(EpcRepository): def __init__(self, by_property: Optional[dict[int, EpcPropertyData]] = None) -> None: self.saved: list[tuple[EpcPropertyData, Optional[int]]] = [] self._by_property = by_property or {} # Predicted EPCs live in their own slot, coexisting with lodged (ADR-0031). self._predicted_by_property: dict[int, EpcPropertyData] = {} def save( self, data: EpcPropertyData, property_id: Optional[int] = None, portfolio_id: Optional[int] = None, source: EpcSource = "lodged", ) -> int: self.saved.append((data, property_id)) if property_id is not None: slot = ( self._predicted_by_property if source == "predicted" else self._by_property ) slot[property_id] = data return len(self.saved) def get(self, epc_property_id: int) -> EpcPropertyData: # pragma: no cover raise NotImplementedError def get_for_property(self, property_id: int) -> Optional[EpcPropertyData]: return self._by_property.get(property_id) def get_predicted_for_property( self, property_id: int ) -> Optional[EpcPropertyData]: return self._predicted_by_property.get(property_id) def get_for_properties( self, property_ids: list[int] ) -> dict[int, EpcPropertyData]: return { property_id: self._by_property[property_id] for property_id in property_ids if property_id in self._by_property } def get_predicted_for_properties( self, property_ids: list[int] ) -> dict[int, EpcPropertyData]: return { property_id: self._predicted_by_property[property_id] for property_id in property_ids if property_id in self._predicted_by_property } class FakeSolarRepo(SolarRepository): """In-memory Google Solar insights store keyed by UPRN. Seed `by_uprn` to hydrate a UPRN's potential for a Modelling read; `saved` records writes (uprn, longitude, latitude, insights) for an Ingestion-side assertion. Returns None for an unseeded UPRN (no solar data fetched) — the solar Generator then offers nothing.""" def __init__(self, by_uprn: Optional[dict[int, dict[str, Any]]] = None) -> None: self.saved: list[tuple[int, float, float, dict[str, Any]]] = [] self._by_uprn: dict[int, dict[str, Any]] = dict(by_uprn or {}) def save( self, uprn: int, *, longitude: float, latitude: float, insights: dict[str, Any] ) -> None: self.saved.append((uprn, longitude, latitude, insights)) self._by_uprn[uprn] = insights def get(self, uprn: int) -> Optional[dict[str, Any]]: return self._by_uprn.get(uprn) class FakeSpatialRepo(SpatialRepository): """In-memory per-UPRN spatial cache. Seed `by_uprn` to hydrate Properties in a read test; `saved` records writes for an Ingestion-side assertion.""" def __init__( self, by_uprn: Optional[dict[int, PlanningRestrictions]] = None ) -> None: self._by_uprn: dict[int, PlanningRestrictions] = dict(by_uprn or {}) self.saved: list[tuple[int, SpatialReference]] = [] def save(self, uprn: int, reference: SpatialReference) -> None: self.saved.append((uprn, reference)) self._by_uprn[uprn] = reference.restrictions def get_for_uprns(self, uprns: list[int]) -> dict[int, PlanningRestrictions]: return {uprn: self._by_uprn[uprn] for uprn in uprns if uprn in self._by_uprn} class FakePropertyBaselineRepo(PropertyBaselineRepository): def __init__(self) -> None: self.saved: list[tuple[PropertyBaselinePerformance, int]] = [] def save(self, baseline: PropertyBaselinePerformance, property_id: int) -> int: self.saved.append((baseline, property_id)) return len(self.saved) def get_for_property( self, property_id: int ) -> Optional[PropertyBaselinePerformance]: # pragma: no cover 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.""" def __init__( self, *, property: FakePropertyRepo, epc: Optional[FakeEpcRepo] = None, solar: Optional[FakeSolarRepo] = None, spatial: Optional[FakeSpatialRepo] = 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.spatial = spatial or FakeSpatialRepo() 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": return self def __exit__( self, exc_type: Optional[type[BaseException]], exc: Optional[BaseException], tb: Optional[TracebackType], ) -> None: return None def commit(self) -> None: self.commits += 1 def rollback(self) -> None: return None