|
14 | 14 |
|
15 | 15 | from datetime import datetime, timezone |
16 | 16 |
|
| 17 | +from component_base import ComponentBase |
17 | 18 | from mock_base import MockBase |
18 | 19 |
|
19 | 20 |
|
| 21 | +class _ContractProbeComponent(ComponentBase): |
| 22 | + """Minimal concrete ComponentBase subclass used only to exercise the base contract.""" |
| 23 | + |
| 24 | + def initialize(self, **kwargs): |
| 25 | + """Do nothing; this probe only needs the base ComponentBase wiring, not extra state.""" |
| 26 | + pass |
| 27 | + |
| 28 | + |
20 | 29 | def test_mock_base_attribute_superset(my_predbat): |
21 | 30 | """Every attribute ComponentBase dereferences off self.base is present after construction.""" |
22 | 31 | base = MockBase() |
@@ -48,6 +57,48 @@ def test_mock_base_attribute_superset(my_predbat): |
48 | 57 | return False |
49 | 58 |
|
50 | 59 |
|
| 60 | +def test_mock_base_covers_component_base_contract(my_predbat): |
| 61 | + """MockBase must satisfy every property and delegated method ComponentBase exposes. |
| 62 | +
|
| 63 | + test_mock_base_attribute_superset only checks that MockBase's attribute list mirrors this |
| 64 | + file's own hardcoded copy of the ComponentBase surface - it is a change-detector, not a |
| 65 | + regression test, because both lists drift together. This test instead binds a real |
| 66 | + ComponentBase subclass to a MockBase and exercises the actual properties and delegate |
| 67 | + methods ComponentBase defines, so it fails with an AttributeError if someone adds a new |
| 68 | + self.base.<something> dereference to component_base.py that the mock does not cover. |
| 69 | + """ |
| 70 | + component = _ContractProbeComponent(MockBase()) |
| 71 | + |
| 72 | + # Properties defined on ComponentBase that read through to self.base. |
| 73 | + assert component.currency_symbols == "£p", "currency_symbols property failed" |
| 74 | + assert component.arg_errors == {}, "arg_errors property failed" |
| 75 | + assert component.now_utc is not None, "now_utc property failed" |
| 76 | + assert component.midnight_utc is not None, "midnight_utc property failed" |
| 77 | + assert component.now_utc_exact is not None, "now_utc_exact property failed" |
| 78 | + assert isinstance(component.minutes_now, int), "minutes_now property failed" |
| 79 | + assert component.plan_interval_minutes == 30, "plan_interval_minutes property failed" |
| 80 | + assert component.num_cars == 0, "num_cars property failed" |
| 81 | + assert component.config_root == "./temp_predbat", "config_root property failed" |
| 82 | + assert component.storage is None, "storage property failed (components is None on MockBase)" |
| 83 | + assert component.fatal_error is False, "fatal_error property failed" |
| 84 | + |
| 85 | + # Methods ComponentBase delegates straight through to self.base. |
| 86 | + assert component.get_arg("missing_key", "fallback") == "fallback", "get_arg delegate failed" |
| 87 | + component.set_arg("probe_key", "probe_value") |
| 88 | + assert component.get_arg("probe_key") == "probe_value", "set_arg delegate failed" |
| 89 | + component.dashboard_item("sensor.predbat_probe", "on", {"friendly_name": "Probe"}) |
| 90 | + assert component.get_ha_config("anything", "fallback") == "fallback", "get_ha_config delegate failed" |
| 91 | + assert component.get_state_wrapper("sensor.predbat_probe") == "on", "get_state_wrapper delegate failed" |
| 92 | + component.set_state_wrapper("sensor.predbat_probe2", "off") |
| 93 | + assert component.get_state_wrapper("sensor.predbat_probe2") == "off", "set_state_wrapper delegate failed" |
| 94 | + assert component.get_history_wrapper("sensor.predbat_probe") is None, "get_history_wrapper delegate failed" |
| 95 | + component.call_notify("probe notification") |
| 96 | + component.log("probe log message") |
| 97 | + |
| 98 | + print("PASS: MockBase covers the full ComponentBase property/delegate contract") |
| 99 | + return False |
| 100 | + |
| 101 | + |
51 | 102 | def test_mock_base_config_root_and_local_tz_overrides(my_predbat): |
52 | 103 | """config_root and local_tz are constructor-overridable, as the axle/gecloud/octopus/solax subclasses need.""" |
53 | 104 | base = MockBase(config_root="./temp_example") |
@@ -101,6 +152,65 @@ def test_mock_base_arg_round_trip(my_predbat): |
101 | 152 | return False |
102 | 153 |
|
103 | 154 |
|
| 155 | +def test_mock_base_set_arg_none_deletes_key(my_predbat): |
| 156 | + """set_arg(key, None) must delete the key, matching userinterface.py's Fetch.set_arg. |
| 157 | +
|
| 158 | + gecloud.py makes several set_arg(key, None) calls expecting the key to disappear so a |
| 159 | + later get_arg(key, default) falls back to the caller's default rather than returning None. |
| 160 | + """ |
| 161 | + base = MockBase() |
| 162 | + base.set_arg("probe_key", "probe_value") |
| 163 | + assert base.get_arg("probe_key", "fallback") == "probe_value", "set_arg should have stored the value" |
| 164 | + base.set_arg("probe_key", None) |
| 165 | + assert base.get_arg("probe_key", "fallback") == "fallback", "set_arg(key, None) should delete the key, not store None" |
| 166 | + assert "probe_key" not in base.args, "the deleted key must not remain in args" |
| 167 | + print("PASS: MockBase set_arg(key, None) deletes the key") |
| 168 | + return False |
| 169 | + |
| 170 | + |
| 171 | +def test_mock_base_reexport_identity(my_predbat): |
| 172 | + """The five plain re-export modules must expose the identical shared MockBase object. |
| 173 | +
|
| 174 | + deye, enphase, fox and solis are not otherwise exercised anywhere (teslemetry is covered |
| 175 | + incidentally by test_teslemetry.py), so nothing else would catch a botched edit to one of |
| 176 | + those `from mock_base import MockBase` lines - e.g. accidentally defining a local class |
| 177 | + that shadows the shared one. |
| 178 | + """ |
| 179 | + from deye import MockBase as DeyeMockBase |
| 180 | + from enphase import MockBase as EnphaseMockBase |
| 181 | + from fox import MockBase as FoxMockBase |
| 182 | + from solis import MockBase as SolisMockBase |
| 183 | + from teslemetry import MockBase as TeslemetryMockBase |
| 184 | + |
| 185 | + for name, reexported in ( |
| 186 | + ("deye", DeyeMockBase), |
| 187 | + ("enphase", EnphaseMockBase), |
| 188 | + ("fox", FoxMockBase), |
| 189 | + ("solis", SolisMockBase), |
| 190 | + ("teslemetry", TeslemetryMockBase), |
| 191 | + ): |
| 192 | + assert reexported is MockBase, f"{name}.MockBase should be the identical shared mock_base.MockBase object" |
| 193 | + |
| 194 | + from axle import MockBase as AxleMockBase |
| 195 | + from gecloud import MockBase as GECloudMockBase |
| 196 | + from octopus import MockBase as OctopusMockBase |
| 197 | + from sigenergy import MockBase as SigenergyMockBase |
| 198 | + from solax import MockBase as SolaxMockBase |
| 199 | + |
| 200 | + for name, subclass in ( |
| 201 | + ("axle", AxleMockBase), |
| 202 | + ("gecloud", GECloudMockBase), |
| 203 | + ("octopus", OctopusMockBase), |
| 204 | + ("sigenergy", SigenergyMockBase), |
| 205 | + ("solax", SolaxMockBase), |
| 206 | + ): |
| 207 | + assert issubclass(subclass, MockBase), f"{name}.MockBase should be a subclass of the shared mock_base.MockBase" |
| 208 | + assert subclass is not MockBase, f"{name}.MockBase should be its own subclass, not a bare re-export" |
| 209 | + |
| 210 | + print("PASS: the eleven module MockBase names resolve to the shared class or a true subclass of it") |
| 211 | + return False |
| 212 | + |
| 213 | + |
104 | 214 | def test_mock_base_dashboard_item_does_not_mutate_attributes(my_predbat): |
105 | 215 | """dashboard_item must not corrupt the caller's attributes dict when eliding the options list.""" |
106 | 216 | base = MockBase() |
@@ -208,11 +318,14 @@ def test_mock_base_all(my_predbat): |
208 | 318 | """Run all mock_base tests.""" |
209 | 319 | tests = [ |
210 | 320 | ("attribute_superset", test_mock_base_attribute_superset, "Full base attribute superset is present"), |
| 321 | + ("component_base_contract", test_mock_base_covers_component_base_contract, "MockBase satisfies the real ComponentBase property/delegate contract"), |
211 | 322 | ("constructor_overrides", test_mock_base_config_root_and_local_tz_overrides, "config_root and local_tz are overridable"), |
212 | 323 | ("midnight_aware", test_mock_base_midnight_utc_is_aware, "midnight_utc is timezone-aware"), |
213 | 324 | ("kwargs_args", test_mock_base_kwargs_populate_args, "Surplus kwargs populate args"), |
214 | 325 | ("none_kwargs", test_mock_base_none_kwargs_are_skipped, "None kwargs are skipped, False is kept"), |
215 | 326 | ("arg_round_trip", test_mock_base_arg_round_trip, "get_arg/set_arg round-trip"), |
| 327 | + ("set_arg_none_deletes", test_mock_base_set_arg_none_deletes_key, "set_arg(key, None) deletes the key"), |
| 328 | + ("reexport_identity", test_mock_base_reexport_identity, "Re-export and subclass modules resolve to the shared MockBase"), |
216 | 329 | ("dashboard_no_mutate", test_mock_base_dashboard_item_does_not_mutate_attributes, "dashboard_item does not mutate caller attributes"), |
217 | 330 | ("dashboard_datetime", test_mock_base_dashboard_item_serialises_datetime, "dashboard_item serialises datetime attributes"), |
218 | 331 | ("state_wrapper", test_mock_base_state_wrapper_paths, "get_state_wrapper raw/attribute/default paths"), |
|
0 commit comments