S0380.221: default a missing API post_town so the cert stays mappable

A 2026-register cert (4519-9056-4002-0222-4802) omits the top-level
post_town entirely (its town sits only in address_line_3 "BARNSTAPLE").
RdSapSchema21_0_x declares post_town as a required no-default field, so
from_dict raised "missing required field 'post_town'" and blocked the
whole cert from computing.

post_town is address metadata the SAP cascade never reads (no consumer
in domain/sap10_calculator/), so default an absent post_town to "" in a
from_api_response pre-processor (mirroring _normalize_shower_outlets) —
inert for the calculation, keeps the cert mappable. The schema dataclass
can't simply give post_town a default: it is a plain (non-kw_only)
dataclass with 57 required fields after post_town, so a mid-list default
would break field ordering.

Validated: cert 4519 now maps (post_town="") and computes SAP cont 74.68
vs lodged 75. §4 suite 2392 passed; mapper.py pyright unchanged at 32;
new tests suppress reportPrivateUsage (net-zero).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 14:59:37 +00:00
parent d164850dd3
commit aac3f0690a
2 changed files with 51 additions and 0 deletions

View file

@ -1911,6 +1911,7 @@ class EpcPropertyDataMapper:
"""
data = _normalize_shower_outlets(data)
data = _default_missing_post_town(data)
schema = data.get("schema_type", "")
if schema == "RdSAP-Schema-21.0.1":
from datatypes.epc.schema.rdsap_schema_21_0_1 import RdSapSchema21_0_1
@ -2081,6 +2082,26 @@ def _normalize_shower_outlets(data: Dict[str, Any]) -> Dict[str, Any]:
return {**data, "sap_heating": new_sap_heating}
def _default_missing_post_town(data: Dict[str, Any]) -> Dict[str, Any]:
"""Default an absent top-level `post_town` to "" before `from_dict`.
`RdSapSchema21_0_x.post_town` is a required (no-default) field, so a
real-API cert that omits it (observed on a 2026-register cert whose
town sits only in `address_line_3`) makes `from_dict` raise
"missing required field 'post_town'", blocking the whole cert.
`post_town` is address metadata that the SAP cascade never reads, so
defaulting it to "" is inert for the calculation while keeping the
cert mappable. The schema dataclass can't simply give the field a
default it is a plain (non-kw_only) dataclass with 57 required
fields after `post_town`, so a mid-list default would break field
ordering; pre-processing here mirrors `_normalize_shower_outlets`.
Mutates a shallow copy so the caller's dict is untouched."""
if "post_town" in data:
return data
return {**data, "post_town": ""}
def _count_shower_outlets_by_type(
schema_shower_outlets: Any, target_type: int,
) -> Optional[int]:

View file

@ -849,3 +849,33 @@ class TestApiFloorConstructionCode:
# Assert — no raise; None defers to the cascade's Table 19 default.
assert result is None
class TestDefaultMissingPostTown:
"""`_default_missing_post_town` keeps a cert mappable when the
register omits the required `post_town` field (observed on a 2026
cert whose town sits only in address_line_3). post_town is address
metadata the SAP cascade never reads, so defaulting it to "" before
`from_dict` is inert for the calculation."""
def test_absent_post_town_is_defaulted_to_empty_string(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import _default_missing_post_town # pyright: ignore[reportPrivateUsage]
doc: dict[str, object] = {"postcode": "EX31 2LE"}
# Act
result = _default_missing_post_town(doc)
# Assert
assert result["post_town"] == ""
def test_present_post_town_is_untouched(self) -> None:
# Arrange — regression guard: a lodged town passes through.
from datatypes.epc.domain.mapper import _default_missing_post_town # pyright: ignore[reportPrivateUsage]
doc: dict[str, object] = {"post_town": "BARNSTAPLE"}
# Act
result = _default_missing_post_town(doc)
# Assert
assert result["post_town"] == "BARNSTAPLE"