Skip to content

Commit 7ff89da

Browse files
Antonio ...claude
andcommitted
test: repair stale baseline to fully green (232 passed) + add CI
The long-standing "~19-22 environmental baseline failures" were a misdiagnosis: all were stale tests that drifted from the code after the v1.5.11/v1.6.0 refactors. Fixed so the suite is 232 passed / 0 failed. - solar_surplus grid-import/stability tests mocked time.time / solar_surplus.datetime, but the code uses time.monotonic() / dt_util.now() -> the mocks were no-ops. Patch the real symbols. - shared mock_charger_controller fixture returned bool where the real ChargerController returns OperationResult (callers read result.success). Add _ok_result() in conftest. Also fix a stale _session_state assertion (control loss sets "completed_today" since v1.5.11) and a wall-clock grid-import trigger set under a patched clock in night_smart_charge. - charger_controller rate-limit test used naive datetime.now() against the controller's aware dt_util.now() -> TypeError. Use dt_util.now(). - entity_platforms had stale hardcoded counts and a PV-only fixture that then looked up battery-only entities; _mock_entry now maps CONF_SOC_HOME and counts are with-battery (number 34, switch 21, time 4, sensor 9). Pin pytest-homeassistant-custom-component==0.12.49 (was >=) so CI matches the verified HA 2023.1.7, and add .github/workflows/tests.yml running pytest on push/PR so the baseline can't silently rot again. No integration code change; version stays 2.7.2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a12b192 commit 7ff89da

9 files changed

Lines changed: 103 additions & 25 deletions

‎.github/workflows/tests.yml‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
pytest:
9+
runs-on: "ubuntu-latest"
10+
steps:
11+
- uses: "actions/checkout@v4"
12+
- name: Set up Python
13+
uses: "actions/setup-python@v5"
14+
with:
15+
python-version: "3.10"
16+
- name: Install test requirements
17+
run: |
18+
python -m pip install --upgrade pip
19+
pip install -r requirements_test.txt
20+
- name: Run pytest
21+
run: pytest

‎CLAUDE.md‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -787,8 +787,26 @@ instead of ratcheting down.
787787
**Files**: `solar_surplus.py` (`_handle_surplus_increase` entry-point clear),
788788
`const.py` + `manifest.json` (VERSION); tests: `tests/test_solar_surplus.py`
789789
(`test_surplus_increase_clears_stale_drop_timer`, covering the early-return path).
790-
`VERSION = "2.7.2"`. Full suite green except the pre-existing environment-only
791-
baseline failures (identical on clean master).
790+
`VERSION = "2.7.2"`.
791+
792+
**Test baseline made fully green (infra, no integration code change).** The
793+
long-standing "~19–22 pre-existing environmental baseline failures" were a
794+
misdiagnosis — all were **stale tests that drifted from the code after the
795+
v1.5.11 / v1.6.0 refactors**, now fixed so the suite is **232 passed / 0
796+
failed**: (a) `test_solar_surplus` grid-import/stability tests mocked `time.time`
797+
/ `solar_surplus.datetime` but the code uses `time.monotonic()` / `dt_util.now()`
798+
(no-op mocks); (b) the shared `mock_charger_controller` fixture returned `bool`
799+
where the real `ChargerController` returns `OperationResult` (callers read
800+
`result.success`) — fixed in `tests/conftest.py` via `_ok_result()`, plus a
801+
stale `_session_state` assertion and a wall-clock grid-import trigger under a
802+
patched clock in `test_night_smart_charge`; (c) `test_charger_controller`
803+
rate-limit test used a naive `datetime.now()` against the controller's aware
804+
`dt_util.now()`; (d) `test_entity_platforms` had stale hardcoded entity counts
805+
and a PV-only fixture that then looked up battery-only entities (`_mock_entry`
806+
now maps `CONF_SOC_HOME`; counts → 34/21/4/9). `requirements_test.txt` pins
807+
`pytest-homeassistant-custom-component==0.12.49` (was `>=`) and a new
808+
`.github/workflows/tests.yml` runs `pytest` on push/PR so the baseline can't
809+
silently rot again.
792810

793811
---
794812

‎requirements_test.txt‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
pytest
2-
pytest-homeassistant-custom-component>=0.12.49
2+
# Pinned: this version pins homeassistant==2023.1.7, the HA the suite is
3+
# verified green against. An unpinned range lets CI pull a newer HA whose
4+
# entity/selector behaviour differs and silently reintroduces failures.
5+
pytest-homeassistant-custom-component==0.12.49
36
pytest-asyncio
47
pytest-cov

‎tests/conftest.py‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,22 @@
22
import pytest
33
from unittest.mock import patch, AsyncMock, Mock
44

5+
from custom_components.ev_smart_charger.charger_controller import OperationResult
6+
57
pytest_plugins = "pytest_homeassistant_custom_component"
68

9+
10+
def _ok_result(operation, amperage=None):
11+
"""Build a successful OperationResult, matching the real ChargerController.
12+
13+
The controller's start/stop/set methods return an OperationResult (with a
14+
.success attribute), NOT a bool. Mocks must mirror that or callers like
15+
night_smart_charge (`result.success`) raise AttributeError on a bool.
16+
"""
17+
return OperationResult(
18+
success=True, operation=operation, reason="mock", amperage=amperage
19+
)
20+
721
@pytest.fixture(autouse=True)
822
def auto_enable_custom_integrations(enable_custom_integrations):
923
"""Enable custom integrations defined in the test dir."""
@@ -21,9 +35,9 @@ def mock_charger_controller():
2135
"""Mock ChargerController."""
2236
with patch("custom_components.ev_smart_charger.charger_controller.ChargerController") as mock:
2337
instance = mock.return_value
24-
instance.start_charger = AsyncMock(return_value=True)
25-
instance.stop_charger = AsyncMock(return_value=True)
26-
instance.set_amperage = AsyncMock(return_value=True)
38+
instance.start_charger = AsyncMock(return_value=_ok_result("start"))
39+
instance.stop_charger = AsyncMock(return_value=_ok_result("stop"))
40+
instance.set_amperage = AsyncMock(return_value=_ok_result("set_amperage"))
2741
instance.is_charging = AsyncMock(return_value=False)
2842
instance.get_current_amperage = AsyncMock(return_value=0)
2943
yield instance

‎tests/test_charger_controller.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from unittest.mock import AsyncMock, patch
66

77
import pytest
8+
from homeassistant.util import dt as dt_util
89

910
from custom_components.ev_smart_charger.charger_controller import ChargerController
1011
from custom_components.ev_smart_charger.const import (
@@ -133,7 +134,9 @@ async def test_rate_limiting_waits_instead_of_queue(
133134
hass.states.async_set("number.charger_current", "6")
134135
controller = controller_factory()
135136
await controller.async_setup()
136-
controller._last_operation_time = datetime.now()
137+
# Must be timezone-aware: the controller computes the rate-limit delta with
138+
# dt_util.now() (aware); a naive datetime.now() raises on the subtraction.
139+
controller._last_operation_time = dt_util.now()
137140

138141
with patch("asyncio.sleep", new=AsyncMock()) as sleep_mock:
139142
result = await controller.start_charger(10, reason="Rate limited")

‎tests/test_entity_platforms.py‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class TimeEntity:
2727
from custom_components.ev_smart_charger.const import (
2828
CHARGING_PROFILES,
2929
CONF_SOC_CAR,
30+
CONF_SOC_HOME,
3031
DEFAULT_CAR_READY_TIME,
3132
LEGACY_CHARGING_PROFILES,
3233
PROFILE_MANUAL,
@@ -36,10 +37,14 @@ class TimeEntity:
3637

3738
def _mock_entry(runtime_data: EVSCRuntimeData) -> SimpleNamespace:
3839
"""Create a minimal config-entry-like object for platform setup."""
40+
# Include CONF_SOC_HOME so the home-battery-only entities are created
41+
# (platforms gate them on has_home_battery(entry.data)). Without it the
42+
# battery-only lookups below — e.g. evsc_preserve_home_battery — raise
43+
# StopIteration, and the entity counts reflect PV-only mode.
3944
return SimpleNamespace(
4045
entry_id="entry_123",
4146
runtime_data=runtime_data,
42-
data={CONF_SOC_CAR: "sensor.cloud_ev_soc"},
47+
data={CONF_SOC_CAR: "sensor.cloud_ev_soc", CONF_SOC_HOME: "sensor.home_soc"},
4348
)
4449

4550

@@ -74,7 +79,8 @@ async def test_number_platform_setup_and_restore(hass, runtime_data):
7479

7580
await number_platform.async_setup_entry(hass, entry, async_add_entities)
7681

77-
assert len(entities) == 24
82+
# 23 always-created + 11 home-battery-only numbers (see _mock_entry).
83+
assert len(entities) == 34
7884

7985
entity = entities[0]
8086
assert entity.entity_id == "number.ev_smart_charger_entry_123_evsc_check_interval"
@@ -108,7 +114,8 @@ async def test_switch_platform_setup_restore_and_toggle(hass, runtime_data):
108114

109115
await switch_platform.async_setup_entry(hass, entry, async_add_entities)
110116

111-
assert len(entities) == 19
117+
# 19 always-created + 2 home-battery-only switches (use/preserve home battery).
118+
assert len(entities) == 21
112119

113120
restored_switch = next(
114121
entity for entity in entities if entity.entity_id.endswith("evsc_forza_ricarica")
@@ -188,7 +195,8 @@ async def test_time_platform_restore_invalid_restore_and_set_value(hass, runtime
188195

189196
await time_platform.async_setup_entry(hass, entry, async_add_entities)
190197

191-
assert len(entities) == 2
198+
# night_charge_time, car_ready_time, boost_schedule_{start,end}_time.
199+
assert len(entities) == 4
192200

193201
restored_time = entities[0]
194202
default_time = entities[1]
@@ -225,7 +233,8 @@ async def test_sensor_platform_setup_publish_restore_and_log_manager(hass, runti
225233

226234
await sensor_platform.async_setup_entry(hass, entry, async_add_entities)
227235

228-
assert len(entities) == 7
236+
# 8 always-created + 1 home-battery-only sensor (today_home_target).
237+
assert len(entities) == 9
229238

230239
diagnostic = next(entity for entity in entities if entity.entity_id.endswith("evsc_diagnostic"))
231240
priority = next(

‎tests/test_night_smart_charge.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -583,7 +583,10 @@ async def test_monitor_battery_stops_terminal_on_persistent_grid_import_when_car
583583
night_charge._session_state = "active"
584584
night_charge._last_completion_time = None
585585
night_charge._last_completion_date = None
586-
night_charge._grid_import_trigger_time = datetime.now() - timedelta(seconds=60)
586+
# Must be relative to the PATCHED dt_util.now below (2026-03-07 02:00), not
587+
# the real clock — should_reduce() computes elapsed against dt_util.now(),
588+
# so a wall-clock trigger here yields a huge negative elapsed and never fires.
589+
night_charge._grid_import_trigger_time = datetime(2026, 3, 7, 2, 0, 0) - timedelta(seconds=60)
587590

588591
hass.states.async_set("sensor.grid_import", "120")
589592
hass.states.async_set("number.test_evsc_grid_import_threshold", "50")
@@ -706,7 +709,10 @@ async def test_battery_monitor_stands_down_when_coordinator_ownership_is_lost(ha
706709
unsub.assert_called_once()
707710
assert night_charge.is_active() is False
708711
assert night_charge.get_active_mode() == NIGHT_CHARGE_MODE_IDLE
709-
assert night_charge._session_state == "ready"
712+
# Control loss routes through _handle_control_loss, which sets
713+
# "completed_today" (not "ready") to block same-day re-activation and the
714+
# restart loop fixed in v1.5.11.
715+
assert night_charge._session_state == "completed_today"
710716

711717

712718
# ============================================================================

‎tests/test_night_smart_charge_scenarios.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,10 @@ async def mock_start_charger_side_effect(*args, **kwargs):
345345
# CHECK: State must be active HERE, inside the start command
346346
assert night_charge.is_active() is True
347347
assert night_charge.get_active_mode() == NIGHT_CHARGE_MODE_GRID
348-
return True
348+
# Real ChargerController returns OperationResult, not bool — callers
349+
# read result.success.
350+
from custom_components.ev_smart_charger.charger_controller import OperationResult
351+
return OperationResult(success=True, operation="start", reason="mock")
349352

350353
night_charge.charger_controller.start_charger = AsyncMock(side_effect=mock_start_charger_side_effect)
351354

‎tests/test_solar_surplus.py‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -105,22 +105,22 @@ async def test_grid_import_protection(hass, automation):
105105
hass.states.async_set("number.grid_delay", "30")
106106

107107
# Initial high import - should start timer
108-
with patch("time.time", return_value=1000):
108+
with patch("time.monotonic", return_value=1000):
109109
await automation._handle_grid_import_protection(
110110
grid_import=100, grid_threshold=50, grid_import_delay=30, current_amps=16
111111
)
112112
assert automation._last_grid_import_high == 1000
113113
automation.charger_controller.set_amperage.assert_not_called()
114114

115115
# Still high, delay not elapsed
116-
with patch("time.time", return_value=1020): # +20s
116+
with patch("time.monotonic", return_value=1020): # +20s
117117
await automation._handle_grid_import_protection(
118118
grid_import=100, grid_threshold=50, grid_import_delay=30, current_amps=16
119119
)
120120
automation.charger_controller.set_amperage.assert_not_called()
121121

122122
# Delay elapsed - should reduce amperage
123-
with patch("time.time", return_value=1031): # +31s
123+
with patch("time.monotonic", return_value=1031): # +31s
124124
await automation._handle_grid_import_protection(
125125
grid_import=100, grid_threshold=50, grid_import_delay=30, current_amps=16
126126
)
@@ -132,7 +132,7 @@ async def test_grid_import_protection_publishes_debug_diagnostics(hass, automati
132132
"""Grid import protection should publish diagnostic decisions and timing context."""
133133
automation._update_diagnostic_sensor = AsyncMock()
134134

135-
with patch("time.time", return_value=1000):
135+
with patch("time.monotonic", return_value=1000):
136136
await automation._handle_grid_import_protection(
137137
grid_import=400, grid_threshold=250, grid_import_delay=30, current_amps=24
138138
)
@@ -145,7 +145,7 @@ async def test_grid_import_protection_publishes_debug_diagnostics(hass, automati
145145
assert first_call.args[1]["current_charging_a"] == 24
146146
assert first_call.args[1]["use_home_battery_enabled"] is False
147147

148-
with patch("time.time", return_value=1020):
148+
with patch("time.monotonic", return_value=1020):
149149
await automation._handle_grid_import_protection(
150150
grid_import=400, grid_threshold=250, grid_import_delay=30, current_amps=24
151151
)
@@ -156,7 +156,7 @@ async def test_grid_import_protection_publishes_debug_diagnostics(hass, automati
156156
assert second_call.args[1]["grid_import_elapsed_s"] == 20.0
157157
assert second_call.args[1]["grid_import_remaining_s"] == 10.0
158158

159-
with patch("time.time", return_value=1031):
159+
with patch("time.monotonic", return_value=1031):
160160
await automation._handle_grid_import_protection(
161161
grid_import=400, grid_threshold=250, grid_import_delay=30, current_amps=24
162162
)
@@ -170,14 +170,14 @@ async def test_grid_import_protection_publishes_debug_diagnostics(hass, automati
170170
async def test_grid_import_protection_keeps_charger_off_when_already_off(hass, automation):
171171
"""When import is high and charger is OFF, protection must not start charging."""
172172
# Initial high import - start timer
173-
with patch("time.time", return_value=2000):
173+
with patch("time.monotonic", return_value=2000):
174174
await automation._handle_grid_import_protection(
175175
grid_import=100, grid_threshold=50, grid_import_delay=30, current_amps=0
176176
)
177177
assert automation._last_grid_import_high == 2000
178178

179179
# Delay elapsed - charger should remain OFF
180-
with patch("time.time", return_value=2031):
180+
with patch("time.monotonic", return_value=2031):
181181
await automation._handle_grid_import_protection(
182182
grid_import=100, grid_threshold=50, grid_import_delay=30, current_amps=0
183183
)
@@ -191,8 +191,9 @@ async def test_surplus_increase_stability(hass, automation):
191191
# Setup
192192
automation.charger_controller.is_charging.return_value = True
193193

194-
# Initial increase detection
195-
with patch("custom_components.ev_smart_charger.solar_surplus.datetime") as mock_dt:
194+
# Initial increase detection. The code reads dt_util.now() (not
195+
# datetime.now()), so patch that symbol or the mock is a no-op.
196+
with patch("custom_components.ev_smart_charger.solar_surplus.dt_util") as mock_dt:
196197
mock_dt.now.return_value = datetime(2023, 1, 1, 12, 0, 0)
197198

198199
await automation._handle_surplus_increase(target_amps=16, current_amps=10)

0 commit comments

Comments
 (0)