mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Add a `predicted_epc` slot to the Property aggregate and a "predicted" branch to SourcePath / source_path / effective_epc (ADR-0031 decisions 1+3). A neighbour-synthesised EpcPropertyData resolves as the Effective EPC ONLY when there is neither a lodged EPC nor Site Notes — a real source always wins (prediction is last-resort gap-fill). The slot is distinct from `epc` so a predicted picture coexists with any lodged one (provenance is structural, not a flag on EpcPropertyData); downstream consumers are untouched. 3 tests: predicted resolves when sole source; lodged EPC wins over predicted; Site Notes win over predicted. 10/10 green, pyright strict clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
165 lines
5 KiB
Python
165 lines
5 KiB
Python
"""Property aggregate — source-path precedence and Effective EPC resolution.
|
|
|
|
The two disjoint source paths (ADR-0001): a Property is modelled either from its
|
|
Site Notes alone, or from the public EPC (with Landlord Overrides, once that slice
|
|
lands). When both exist, the newer wins (Recency Tie-Break).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
|
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
|
from domain.property.properties import Properties
|
|
from domain.property.property import Property, PropertyIdentity
|
|
from domain.property.site_notes import SiteNotes
|
|
|
|
_JSON_SAMPLES = Path(__file__).resolve().parents[3] / "backend/epc_api/json_samples"
|
|
|
|
|
|
def _epc(inspection: str = "2023-12-01") -> EpcPropertyData:
|
|
raw: dict[str, Any] = json.loads(
|
|
(_JSON_SAMPLES / "RdSAP-Schema-21.0.0" / "epc.json").read_text()
|
|
)
|
|
return EpcPropertyDataMapper.from_api_response(raw)
|
|
|
|
|
|
def _identity() -> PropertyIdentity:
|
|
return PropertyIdentity(
|
|
portfolio_id=1, postcode="A0 0AA", address="1 Some Street", uprn=12345
|
|
)
|
|
|
|
|
|
def test_source_path_is_epc_with_overlay_when_only_epc_present() -> None:
|
|
# Arrange
|
|
prop = Property(identity=_identity(), epc=_epc())
|
|
|
|
# Act
|
|
path = prop.source_path
|
|
|
|
# Assert
|
|
assert path == "epc_with_overlay"
|
|
|
|
|
|
def test_source_path_is_site_notes_when_only_site_notes_present() -> None:
|
|
# Arrange
|
|
prop = Property(
|
|
identity=_identity(),
|
|
site_notes=SiteNotes(surveyed_at=date(2024, 6, 1), epc=_epc()),
|
|
)
|
|
|
|
# Act
|
|
path = prop.source_path
|
|
|
|
# Assert
|
|
assert path == "site_notes"
|
|
|
|
|
|
def test_recency_tie_break_newer_site_notes_win_over_older_epc() -> None:
|
|
# Arrange — EPC inspected 2023-12-01; survey is newer
|
|
prop = Property(
|
|
identity=_identity(),
|
|
epc=_epc(),
|
|
site_notes=SiteNotes(surveyed_at=date(2025, 1, 1), epc=_epc()),
|
|
)
|
|
|
|
# Act / Assert
|
|
assert prop.source_path == "site_notes"
|
|
|
|
|
|
def test_recency_tie_break_older_site_notes_lose_to_newer_epc() -> None:
|
|
# Arrange — survey predates the EPC's inspection date
|
|
prop = Property(
|
|
identity=_identity(),
|
|
epc=_epc(),
|
|
site_notes=SiteNotes(surveyed_at=date(2020, 1, 1), epc=_epc()),
|
|
)
|
|
|
|
# Act / Assert
|
|
assert prop.source_path == "epc_with_overlay"
|
|
|
|
|
|
def test_effective_epc_follows_the_selected_source_path() -> None:
|
|
# Arrange
|
|
survey_epc = _epc()
|
|
public_epc = _epc()
|
|
site_notes_property = Property(
|
|
identity=_identity(),
|
|
site_notes=SiteNotes(surveyed_at=date(2025, 1, 1), epc=survey_epc),
|
|
)
|
|
epc_property = Property(identity=_identity(), epc=public_epc)
|
|
|
|
# Act / Assert
|
|
assert site_notes_property.effective_epc is survey_epc
|
|
assert epc_property.effective_epc is public_epc
|
|
|
|
|
|
def test_source_path_is_predicted_when_only_a_predicted_epc_is_present() -> None:
|
|
# Arrange — no lodged EPC, no Site Notes; just a neighbour-synthesised picture
|
|
# (EPC Prediction gap-fill, ADR-0031).
|
|
predicted = _epc()
|
|
prop = Property(identity=_identity(), predicted_epc=predicted)
|
|
|
|
# Act / Assert — predicted is the last-resort source, not a raise
|
|
assert prop.source_path == "predicted"
|
|
assert prop.effective_epc is predicted
|
|
|
|
|
|
def test_a_lodged_epc_wins_over_a_predicted_epc() -> None:
|
|
# Arrange — both a real lodged EPC and a neighbour-synthesised one are present;
|
|
# the real source must win (prediction is last-resort only, ADR-0031).
|
|
lodged = _epc()
|
|
predicted = _epc()
|
|
prop = Property(identity=_identity(), epc=lodged, predicted_epc=predicted)
|
|
|
|
# Act / Assert
|
|
assert prop.source_path == "epc_with_overlay"
|
|
assert prop.effective_epc is lodged
|
|
|
|
|
|
def test_site_notes_win_over_a_predicted_epc() -> None:
|
|
# Arrange — Site Notes and a predicted EPC are present; the survey wins.
|
|
survey_epc = _epc()
|
|
predicted = _epc()
|
|
prop = Property(
|
|
identity=_identity(),
|
|
site_notes=SiteNotes(surveyed_at=date(2024, 6, 1), epc=survey_epc),
|
|
predicted_epc=predicted,
|
|
)
|
|
|
|
# Act / Assert
|
|
assert prop.source_path == "site_notes"
|
|
assert prop.effective_epc is survey_epc
|
|
|
|
|
|
def test_property_with_no_source_raises() -> None:
|
|
# Arrange
|
|
prop = Property(identity=_identity())
|
|
|
|
# Act / Assert
|
|
try:
|
|
_ = prop.source_path
|
|
except ValueError:
|
|
pass
|
|
else: # pragma: no cover
|
|
raise AssertionError("expected ValueError when no source is present")
|
|
|
|
|
|
def test_properties_collection_iterates_and_filters() -> None:
|
|
# Arrange
|
|
with_epc = Property(identity=_identity(), epc=_epc())
|
|
without = Property(identity=_identity())
|
|
properties = Properties([with_epc, without])
|
|
|
|
# Act
|
|
with_source = properties.filter(lambda p: p.epc is not None)
|
|
|
|
# Assert
|
|
assert len(properties) == 2
|
|
assert list(properties) == [with_epc, without]
|
|
assert len(with_source) == 1
|
|
assert list(with_source) == [with_epc]
|