from typing import Optional import pytest from scripts.audit.anomalies import ( PropertyAudit, _effective_lodged_divergence, _implausible_lodged_score, _plan_stops_short_of_goal, ) def _make_audit( *, lodged_sap: Optional[float] = None, effective_sap: Optional[float] = None, rebaseline_reason: str = "both", scenario_goal_band: Optional[str] = None, scenario_budget: Optional[float] = None, post_band: Optional[str] = None, post_sap: Optional[float] = None, cost_of_works: Optional[float] = None, ) -> PropertyAudit: return PropertyAudit( property_id=1, uprn=None, portfolio_id=796, scenario_id=None, scenario_goal_band=scenario_goal_band, scenario_budget=scenario_budget, lodged_sap=lodged_sap, lodged_band=None, effective_sap=effective_sap, effective_band=None, rebaseline_reason=rebaseline_reason, post_sap=post_sap, post_band=post_band, cost_of_works=cost_of_works, energy_bill_savings=None, energy_consumption_savings=None, solar_sap_points=None, solar_bill_savings=None, n_measures=0, ) class TestEffectiveLodgedDivergence: def test_silent_when_lodged_sap_below_floor(self) -> None: # Arrange — Class C exemplar: lodged=1, effective=76 (gap +75, but lodged is implausible upstream data) audit = _make_audit(lodged_sap=1.0, effective_sap=76.0) # Act result = _effective_lodged_divergence(audit) # Assert assert result is None def test_fires_when_lodged_sap_at_or_above_floor_with_large_gap(self) -> None: # Arrange — lodged=20 (band G, plausible), effective=65 (band D), gap +45 audit = _make_audit(lodged_sap=20.0, effective_sap=65.0) # Act result = _effective_lodged_divergence(audit) # Assert assert result is not None assert "65" in result assert "20" in result class TestImplausibleLodgedScore: def test_fires_when_lodged_sap_below_floor_and_gap_large(self) -> None: # Arrange — Class C exemplar: lodged=1, effective=76 (gap +75) audit = _make_audit(lodged_sap=1.0, effective_sap=76.0) # Act result = _implausible_lodged_score(audit) # Assert assert result is not None assert "1" in result def test_silent_when_gap_below_threshold(self) -> None: # Arrange — lodged=9, effective=5 (gap -4, below ±15): both low, not a divergence anomaly audit = _make_audit(lodged_sap=9.0, effective_sap=5.0) # Act result = _implausible_lodged_score(audit) # Assert assert result is None def test_silent_when_lodged_sap_at_or_above_floor(self) -> None: # Arrange — lodged=13 is on the floor boundary; effective=70 gives large gap audit = _make_audit(lodged_sap=13.0, effective_sap=70.0) # Act result = _implausible_lodged_score(audit) # Assert assert result is None class TestPlanStopsShortOfGoal: def test_fires_when_short_of_goal_on_unlimited_budget(self) -> None: # Arrange — goal C, plan lands at D, budget unlimited (None): a shortfall # the engine should be able to explain (Khalim's 742121 class). audit = _make_audit( scenario_goal_band="C", scenario_budget=None, post_band="D", post_sap=63.0, cost_of_works=8000.0, ) # Act result = _plan_stops_short_of_goal(audit) # Assert assert result is not None assert "D" in result assert "C" in result def test_silent_when_plan_meets_goal(self) -> None: # Arrange — goal C, plan reaches C: nothing to explain. audit = _make_audit( scenario_goal_band="C", scenario_budget=None, post_band="C", post_sap=70.0 ) # Act result = _plan_stops_short_of_goal(audit) # Assert assert result is None def test_silent_when_budget_capped(self) -> None: # Arrange — goal C, plan short at D, but the scenario is budget-capped: # falling short is expected once the money runs out, not an anomaly. audit = _make_audit( scenario_goal_band="C", scenario_budget=5000.0, post_band="D", post_sap=63.0, cost_of_works=5000.0, ) # Act result = _plan_stops_short_of_goal(audit) # Assert assert result is None