mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
The Plan derives its Valuation Uplift (ADR-0018) from its baseline -> post band jump and works+contingency cost, given one external input — the Property's current market value (a Property Valuation, mostly absent). `Plan.valuation` / `Plan.baseline_epc_rating` are derived like the other headline figures; `PlanModel.from_domain` maps the £ forms to the live plan.valuation_* columns (NULL when no value — the percentage is not persisted on those columns). `Property.current_market_value` is the new optional source; the orchestrator threads it onto the Plan. `run_one` takes a `current_market_value` so the harness can value the uplift, and the sense-check table shows the average % (always) plus the £ forms when known. Sourcing the current market value (upload / default) remains deferred (ADR-0018); it is None throughout until that lands, so the columns stay NULL at scale. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
202 lines
7.1 KiB
Python
202 lines
7.1 KiB
Python
"""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.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):
|
|
"""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])
|
|
|
|
|
|
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 {}
|
|
|
|
def save(
|
|
self,
|
|
data: EpcPropertyData,
|
|
property_id: Optional[int] = None,
|
|
portfolio_id: Optional[int] = None,
|
|
) -> int:
|
|
self.saved.append((data, property_id))
|
|
if property_id is not None:
|
|
self._by_property[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_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
|
|
}
|
|
|
|
|
|
class FakeSolarRepo(SolarRepository):
|
|
def __init__(self) -> None:
|
|
self.saved: list[tuple[int, dict[str, Any]]] = []
|
|
|
|
def save(self, property_id: int, insights: dict[str, Any]) -> None:
|
|
self.saved.append((property_id, insights))
|
|
|
|
def get(self, property_id: int) -> Optional[dict[str, Any]]: # pragma: no cover
|
|
raise NotImplementedError
|
|
|
|
|
|
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,
|
|
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":
|
|
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
|