Model/tests/domain/property/test_property.py
Khalim Conn-Kowlessar 086187ddc7 feat(epc-prediction): slice-5a predicted source path on Property
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>
2026-06-16 03:33:47 +00:00

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]