Skip to content

Commit 99902e3

Browse files
fix(mock-base): close review findings on the shared MockBase refactor
- Add a contract test that binds a real ComponentBase subclass to MockBase and exercises every property/delegate method, so an AttributeError fires if a future self.base dereference in component_base.py outgrows the mock (the old attribute_superset test only compared two hardcoded lists). - Fix MockBase.set_arg to pop the key on a None value, matching userinterface.py's Fetch.set_arg semantics instead of storing None. - Add coverage asserting deye/enphase/fox/solis/teslemetry re-export the identical shared MockBase, and axle/gecloud/octopus/sigenergy/solax subclass it. - Widen MockBase.log to accept hass.py's quiet keyword. - Correct two factual errors in the design spec: the octopus.py record_status calls are on the Octopus mixin (Output provides the method), not OctopusAPI/ComponentBase; and deye's old dashboard_item printed no attributes at all, so it gains attribute printing rather than merely losing the options elision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6a98298 commit 99902e3

3 files changed

Lines changed: 137 additions & 10 deletions

File tree

apps/predbat/mock_base.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,12 @@ def __init__(self, config_root="./temp_predbat", local_tz=None, **kwargs):
5252
self.arg_errors = {}
5353
self.args = {key: value for key, value in kwargs.items() if value is not None}
5454

55-
def log(self, message):
56-
"""Print a timestamped log line."""
55+
def log(self, message, quiet=True):
56+
"""Print a timestamped log line.
57+
58+
Accepts the real Hass.log's quiet keyword for signature compatibility with callers
59+
that pass it; the mock always prints regardless of its value.
60+
"""
5761
print(f"[{datetime.now().strftime('%H:%M:%S')}] {message}")
5862

5963
def get_state_wrapper(self, entity_id=None, default=None, attribute=None, refresh=False, required_unit=None, raw=False):
@@ -92,8 +96,16 @@ def get_arg(self, arg, default=None, indirect=True, combine=False, attribute=Non
9296
return self.args.get(arg, default)
9397

9498
def set_arg(self, key, value):
95-
"""Record an argument set by automatic_config, printing it with any referenced entity's state."""
96-
self.args[key] = value
99+
"""Record an argument set by automatic_config, printing it with any referenced entity's state.
100+
101+
Matches userinterface.py's Fetch.set_arg: a None value deletes the key rather than
102+
storing it, so a later get_arg(key, default) falls back to the caller's default
103+
instead of returning None.
104+
"""
105+
if value is None:
106+
self.args.pop(key, None)
107+
else:
108+
self.args[key] = value
97109
if isinstance(value, str) and "." in value:
98110
state = self.get_state_wrapper(value, default=None)
99111
elif isinstance(value, list):

apps/predbat/tests/test_mock_base.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,18 @@
1414

1515
from datetime import datetime, timezone
1616

17+
from component_base import ComponentBase
1718
from mock_base import MockBase
1819

1920

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+
2029
def test_mock_base_attribute_superset(my_predbat):
2130
"""Every attribute ComponentBase dereferences off self.base is present after construction."""
2231
base = MockBase()
@@ -48,6 +57,48 @@ def test_mock_base_attribute_superset(my_predbat):
4857
return False
4958

5059

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+
51102
def test_mock_base_config_root_and_local_tz_overrides(my_predbat):
52103
"""config_root and local_tz are constructor-overridable, as the axle/gecloud/octopus/solax subclasses need."""
53104
base = MockBase(config_root="./temp_example")
@@ -101,6 +152,65 @@ def test_mock_base_arg_round_trip(my_predbat):
101152
return False
102153

103154

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+
104214
def test_mock_base_dashboard_item_does_not_mutate_attributes(my_predbat):
105215
"""dashboard_item must not corrupt the caller's attributes dict when eliding the options list."""
106216
base = MockBase()
@@ -208,11 +318,14 @@ def test_mock_base_all(my_predbat):
208318
"""Run all mock_base tests."""
209319
tests = [
210320
("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"),
211322
("constructor_overrides", test_mock_base_config_root_and_local_tz_overrides, "config_root and local_tz are overridable"),
212323
("midnight_aware", test_mock_base_midnight_utc_is_aware, "midnight_utc is timezone-aware"),
213324
("kwargs_args", test_mock_base_kwargs_populate_args, "Surplus kwargs populate args"),
214325
("none_kwargs", test_mock_base_none_kwargs_are_skipped, "None kwargs are skipped, False is kept"),
215326
("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"),
216329
("dashboard_no_mutate", test_mock_base_dashboard_item_does_not_mutate_attributes, "dashboard_item does not mutate caller attributes"),
217330
("dashboard_datetime", test_mock_base_dashboard_item_serialises_datetime, "dashboard_item serialises datetime attributes"),
218331
("state_wrapper", test_mock_base_state_wrapper_paths, "get_state_wrapper raw/attribute/default paths"),

docs/superpowers/specs/2026-07-29-shared-mock-base-design.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,11 @@ The eleven CLI test-double classes listed above.
4747
- **`web_mcp.py:1293`** — an inline mock with an unrelated surface (`raw_plan`, `soc_kw`,
4848
`is_running`); it has no `get_state_wrapper`, `get_arg`, `log` or `dashboard_item`. There
4949
is nothing to share.
50-
- **`self.record_status(...)` calls in `octopus.py`.** `OctopusAPI` inherits `ComponentBase`,
51-
which defines no `record_status` (it lives on `Output`), so those error paths would raise
52-
`AttributeError` regardless of the mock. That is a pre-existing defect on `self`, not
53-
`self.base`, and this work does not address it.
50+
- **`self.record_status(...)` calls in `octopus.py`.** These calls (around
51+
`octopus.py:2067-2334`) are inside `class Octopus`, a PredBat mixin composed alongside
52+
`Output` in the main `PredBat` class, where `Output.record_status` genuinely exists at
53+
runtime. They are unrelated to `OctopusAPI`/`ComponentBase` or to `self.base`, and out of
54+
scope for this work.
5455

5556
## Design
5657

@@ -121,8 +122,9 @@ Three deliberate changes, all confined to CLI-harness behaviour and output:
121122
version adopts that approach. `default=str` (currently only in `axle`) prevents a
122123
`TypeError` when a component publishes a datetime.
123124

124-
Cosmetic consequence: `axle` and `deye` CLI output will now elide `options` as the other
125-
nine already do.
125+
Cosmetic consequence: `axle` CLI output will now elide `options` as the other modules
126+
already do. `deye`'s old `dashboard_item` printed no attributes at all, so it gains
127+
attribute printing outright rather than merely eliding `options`.
126128

127129
3. **`set_arg` logging.** `axle` and `sigenergy` print a terser line; they adopt the common
128130
form that resolves the referenced entity's state. More informative, CLI-only.

0 commit comments

Comments
 (0)