From aac3f0690a785777be8952a293674997902a0740 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 14:59:37 +0000 Subject: [PATCH] S0380.221: default a missing API post_town so the cert stays mappable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- datatypes/epc/domain/mapper.py | 21 +++++++++++++ .../domain/tests/test_from_rdsap_schema.py | 30 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index f8a31ae4..2a61bebb 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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]: diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 4f0b80ee..b0711d9f 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -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"