From 1e3b74c03ae451371c7effe5654522dacb875506 Mon Sep 17 00:00:00 2001 From: Filip Lajszczak Date: Sun, 12 Apr 2026 15:00:45 +0000 Subject: [PATCH] tests: characterize core inverter dispatch behavior --- .github/workflows/pytest.yml | 3 +- pyproject.toml | 1 + run_tests.sh | 4 +- tests/batcontrol/test_core.py | 164 +++++++++++++++++++++++++++++++++- 4 files changed, 166 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 50a88623..bfa001e7 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -24,8 +24,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install pytest pytest-cov pytest-asyncio - if [ -f pyproject.toml ]; then pip install -e .; fi + if [ -f pyproject.toml ]; then pip install -e '.[test]'; fi - name: Test with pytest run: | python -m pytest tests/ --cov=src/batcontrol --cov-report=xml diff --git a/pyproject.toml b/pyproject.toml index 953958f6..02513bfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ test = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", "pytest-asyncio>=0.21.0", + "pytest-mock>=3.0.0", ] # Bump Version diff --git a/run_tests.sh b/run_tests.sh index 92dfc251..68c8e3b1 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -5,8 +5,8 @@ if [ -f "./venv/activate" ]; then source ./venv/activate fi -# Install pytest dependencies if not already installed -pip install pytest pytest-cov pytest-asyncio +# Install the package together with test dependencies from pyproject.toml +pip install -e '.[test]' # Run tests with coverage python -m pytest tests/ --cov=src/batcontrol --log-cli-level=DEBUG --log-cli-format="%(asctime)s [%(levelname)8s] %(name)s: %(message)s" --log-cli-date-format="%Y-%m-%d %H:%M:%S" diff --git a/tests/batcontrol/test_core.py b/tests/batcontrol/test_core.py index 633566b6..5b8a6c72 100644 --- a/tests/batcontrol/test_core.py +++ b/tests/batcontrol/test_core.py @@ -27,7 +27,7 @@ def mock_config(self): }, 'utility': { 'type': 'tibber', - 'token': 'test_token' + 'apikey': 'test_token' }, 'pvinstallations': [], 'consumption_forecast': { @@ -305,7 +305,7 @@ def base_mock_config(self): }, 'utility': { 'type': 'tibber', - 'token': 'test_token' + 'apikey': 'test_token' }, 'pvinstallations': [], 'consumption_forecast': { @@ -356,5 +356,165 @@ def test_logic_factory_accepts_string_resolution_as_int(self, resolution_str): assert logic.interval_minutes == int(resolution_str) +class TestCoreRunDispatch: + """Characterize Batcontrol.run() dispatch to inverter methods""" + + @pytest.fixture + def mock_config(self): + return { + 'timezone': 'Europe/Berlin', + 'time_resolution_minutes': 60, + 'inverter': { + 'type': 'dummy', + 'max_grid_charge_rate': 5000, + 'max_pv_charge_rate': 3000, + 'min_pv_charge_rate': 0, + }, + 'utility': { + 'type': 'tibber', + 'apikey': 'test_token' + }, + 'pvinstallations': [], + 'consumption_forecast': { + 'type': 'simple', + 'value': 500 + }, + 'battery_control': { + 'max_charging_from_grid_limit': 0.8, + 'min_price_difference': 0.05 + }, + 'mqtt': {'enabled': False} + } + + @pytest.fixture + def run_dispatch_setup(self, mock_config, mocker): + core_module = "batcontrol.core" + + mock_inverter = mocker.MagicMock() + mock_inverter.max_pv_charge_rate = 3000 + mock_inverter.max_grid_charge_rate = 5000 + mock_inverter.get_max_capacity.return_value = 10000 + mock_inverter.get_SOC.return_value = 50 + mock_inverter.get_stored_energy.return_value = 5000 + mock_inverter.get_stored_usable_energy.return_value = 4500 + mock_inverter.get_free_capacity.return_value = 5000 + + mock_tariff_provider = mocker.MagicMock() + mock_tariff_provider.get_prices.return_value = {0: 0.20, 1: 0.30, 2: 0.25} + mock_tariff_provider.refresh_data = mocker.MagicMock() + + mock_solar_provider = mocker.MagicMock() + mock_solar_provider.get_forecast.return_value = {0: 0, 1: 0, 2: 0} + mock_solar_provider.refresh_data = mocker.MagicMock() + + mock_consumption_provider = mocker.MagicMock() + mock_consumption_provider.get_forecast.return_value = {0: 500, 1: 500, 2: 500} + mock_consumption_provider.refresh_data = mocker.MagicMock() + + fake_logic = mocker.MagicMock() + fake_logic.calculate.return_value = True + fake_logic.get_calculation_output.return_value = mocker.MagicMock( + reserved_energy=0, + required_recharge_energy=0, + min_dynamic_price_difference=0.05, + ) + + mocker.patch( + f"{core_module}.tariff_factory.create_tarif_provider", + autospec=True, + return_value=mock_tariff_provider, + ) + mocker.patch( + f"{core_module}.inverter_factory.create_inverter", + autospec=True, + return_value=mock_inverter, + ) + mocker.patch( + f"{core_module}.solar_factory.create_solar_provider", + autospec=True, + return_value=mock_solar_provider, + ) + mocker.patch( + f"{core_module}.consumption_factory.create_consumption", + autospec=True, + return_value=mock_consumption_provider, + ) + mocker.patch( + f"{core_module}.LogicFactory.create_logic", + autospec=True, + return_value=fake_logic, + ) + + bc = Batcontrol(mock_config) + + yield bc, mock_inverter, fake_logic + + bc.shutdown() + + def test_run_dispatches_allow_discharge(self, run_dispatch_setup): + bc, mock_inverter, fake_logic = run_dispatch_setup + fake_logic.get_inverter_control_settings.return_value = MagicMock( + allow_discharge=True, + charge_from_grid=False, + charge_rate=0, + limit_battery_charge_rate=-1, + ) + + bc.run() + + mock_inverter.set_mode_allow_discharge.assert_called_once_with() + mock_inverter.set_mode_force_charge.assert_not_called() + mock_inverter.set_mode_avoid_discharge.assert_not_called() + mock_inverter.set_mode_limit_battery_charge.assert_not_called() + + def test_run_dispatches_force_charge(self, run_dispatch_setup): + bc, mock_inverter, fake_logic = run_dispatch_setup + fake_logic.get_inverter_control_settings.return_value = MagicMock( + allow_discharge=False, + charge_from_grid=True, + charge_rate=2345, + limit_battery_charge_rate=-1, + ) + + bc.run() + + mock_inverter.set_mode_force_charge.assert_called_once_with(2345) + mock_inverter.set_mode_allow_discharge.assert_not_called() + mock_inverter.set_mode_avoid_discharge.assert_not_called() + mock_inverter.set_mode_limit_battery_charge.assert_not_called() + + def test_run_dispatches_avoid_discharge(self, run_dispatch_setup): + bc, mock_inverter, fake_logic = run_dispatch_setup + fake_logic.get_inverter_control_settings.return_value = MagicMock( + allow_discharge=False, + charge_from_grid=False, + charge_rate=0, + limit_battery_charge_rate=-1, + ) + + bc.run() + + mock_inverter.set_mode_avoid_discharge.assert_called_once_with() + mock_inverter.set_mode_allow_discharge.assert_not_called() + mock_inverter.set_mode_force_charge.assert_not_called() + mock_inverter.set_mode_limit_battery_charge.assert_not_called() + + def test_run_dispatches_limit_battery_charge_rate(self, run_dispatch_setup): + bc, mock_inverter, fake_logic = run_dispatch_setup + fake_logic.get_inverter_control_settings.return_value = MagicMock( + allow_discharge=True, + charge_from_grid=False, + charge_rate=0, + limit_battery_charge_rate=1800, + ) + + bc.run() + + mock_inverter.set_mode_limit_battery_charge.assert_called_once_with(1800) + mock_inverter.set_mode_allow_discharge.assert_not_called() + mock_inverter.set_mode_force_charge.assert_not_called() + mock_inverter.set_mode_avoid_discharge.assert_not_called() + + if __name__ == '__main__': pytest.main([__file__, '-v'])