mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-09-11 10:26:38 +00:00
The isolated pre-commit mypy hook previously omitted runtime type information that make mypy used, hiding errors involving dependencies such as Pydantic and Pendulum. Makefile, pre-commit and CI now run the same full-project typing policy in the development environment defined by uv.lock. - Use uv run --locked --exact --extra dev and the same mypy arguments for Makefile and the local hook. Check all of src and tests, including on configuration-only changes. - Pin Python 3.13 for local development and the pre-commit CI job, and install the locked pre-commit version in CI. - Disable incremental analysis because existing Pendulum cache state changes mypy 2.3.1 diagnostics. Document the policy, the performance tradeoff and the existing typing debt. - Add a regression test that exercises Makefile, the hook and the CI command in a temporary project, accepting valid dependency types and detecting deliberate Pydantic/Pendulum assignment errors. Resolve the newly detected mypy diagnostics. - Enable the numpydantic and Pydantic mypy plugins, retaining strict Pydantic constructor typing with init_typed = true. Validate raw/coercible payloads through model_validate. - Propagate concrete record, provider and time-window types through generic collections, factories and lookup methods. Preserve runtime field inspection and generated time-window documentation. - Align Pendulum annotations with actual factory/arithmetic results while retaining Pydantic validation adapters at runtime. Correct optional values, array boundaries, REST handlers and plotting interfaces. - Add pinned scipy-stubs and types-psutil, update uv.lock, and supply the plugins' dependencies. - Add runtime regression coverage for validated path defaults, normalized time-series metadata, generic field inspection, invalid timestamps and unsupported provider imports. Runtime and compatibility details: - Validate path defaults as Path objects while retaining raw string defaults needed by migration serialization with exclude_defaults. - Normalize feed-in tariff lists and default charge rates to NumPy arrays; reject missing timestamps/uninitialized values explicitly. Importing into a provider without import support returns HTTP 400. - Public JSON schemas and OpenAPI structure match main (excluding the generated version). Signed-off-by: dr-dimitry Signed-off-by: dr-dimitry Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com> Co-authored-by: dr-dimitri <87113560+dr-dimitri@users.noreply.github.com> Co-authored-by: Normann <github@koldrack.com>
135 lines
5.0 KiB
Python
135 lines
5.0 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from pydantic import BaseModel
|
|
|
|
from akkudoktoreos.adapter.adapter import AdapterCommonSettings
|
|
from akkudoktoreos.adapter.nodered import NodeREDAdapter, NodeREDAdapterCommonSettings
|
|
from akkudoktoreos.core.emplan import DDBCInstruction, FRBCInstruction
|
|
from akkudoktoreos.core.ems import EnergyManagementStage
|
|
from akkudoktoreos.utils.datetimeutil import DateTime, compare_datetimes, to_datetime
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_ems() -> MagicMock:
|
|
m = MagicMock()
|
|
m.stage.return_value = EnergyManagementStage.DATA_ACQUISITION
|
|
m.plan.return_value.get_active_instructions.return_value = []
|
|
return m
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter(config_eos, mock_ems: MagicMock) -> NodeREDAdapter:
|
|
"""Fully Pydantic-safe NodeREDAdapter fixture."""
|
|
# Set nested value - also fills None values
|
|
config_eos.set_nested_value("adapter/provider", ["NodeRED"])
|
|
|
|
ad = NodeREDAdapter()
|
|
|
|
# Mark update datetime invalid
|
|
ad.update_datetime = None
|
|
|
|
# Assign EMS
|
|
object.__setattr__(ad, "ems", mock_ems)
|
|
|
|
return ad
|
|
|
|
|
|
class TestNodeREDAdapter:
|
|
|
|
def test_provider_id(self, adapter: NodeREDAdapter):
|
|
assert adapter.provider_id() == "NodeRED"
|
|
|
|
def test_enabled_detection_single(self, adapter: NodeREDAdapter):
|
|
adapter.config.adapter.provider = ["NodeRED"]
|
|
assert adapter.enabled() is True
|
|
adapter.config.adapter.provider = ["HomeAssistant"]
|
|
assert adapter.enabled() is False
|
|
adapter.config.adapter.provider = ["HomeAssistant", "NodeRED"]
|
|
assert adapter.enabled() is True
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("requests.get")
|
|
async def test_update_datetime(self, mock_get, adapter: NodeREDAdapter):
|
|
adapter.ems.stage.return_value = EnergyManagementStage.DATA_ACQUISITION
|
|
mock_get.return_value.status_code = 200
|
|
mock_get.return_value.json.return_value = {"foo": "bar"}
|
|
now = to_datetime()
|
|
|
|
await adapter.update_data(force_enable=True)
|
|
|
|
mock_get.assert_called_once()
|
|
assert adapter.update_datetime is not None
|
|
assert compare_datetimes(adapter.update_datetime, now).approximately_equal
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("requests.get")
|
|
async def test_update_data_data_acquisition_success(self, mock_get , adapter: NodeREDAdapter):
|
|
adapter.ems.stage.return_value = EnergyManagementStage.DATA_ACQUISITION
|
|
mock_get.return_value.status_code = 200
|
|
mock_get.return_value.json.return_value = {"foo": "bar"}
|
|
|
|
await adapter.update_data(force_enable=True)
|
|
|
|
mock_get.assert_called_once()
|
|
url, = mock_get.call_args[0]
|
|
assert "/eos/data_aquisition" in url
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("requests.get", side_effect=Exception("boom"))
|
|
async def test_update_data_data_acquisition_failure(self, mock_get, adapter: NodeREDAdapter):
|
|
adapter.ems.stage.return_value = EnergyManagementStage.DATA_ACQUISITION
|
|
with pytest.raises(RuntimeError):
|
|
await adapter.update_data(force_enable=True)
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("requests.post")
|
|
async def test_update_data_control_dispatch_instructions(self, mock_post, adapter: NodeREDAdapter):
|
|
adapter.ems.stage.return_value = EnergyManagementStage.CONTROL_DISPATCH
|
|
|
|
instr1 = DDBCInstruction(
|
|
id="res1@extra", operation_mode_id="X", operation_mode_factor=0.5,
|
|
actuator_id="dummy", execution_time=to_datetime()
|
|
)
|
|
instr2 = FRBCInstruction(
|
|
id="resA", operation_mode_id="Y", operation_mode_factor=0.25,
|
|
actuator_id="dummy", execution_time=to_datetime()
|
|
)
|
|
adapter.ems.plan.return_value.get_active_instructions.return_value = [instr1, instr2]
|
|
|
|
mock_post.return_value.status_code = 200
|
|
mock_post.return_value.json.return_value = {}
|
|
|
|
await adapter.update_data(force_enable=True)
|
|
|
|
_, kwargs = mock_post.call_args
|
|
payload = kwargs["json"]
|
|
assert payload["res1_op_mode"] == "X"
|
|
assert payload["res1_op_factor"] == 0.5
|
|
assert payload["resA_op_mode"] == "Y"
|
|
assert payload["resA_op_factor"] == 0.25
|
|
url, = mock_post.call_args[0]
|
|
assert "/eos/control_dispatch" in url
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("requests.post")
|
|
async def test_update_data_disabled_provider(self, mock_post, adapter: NodeREDAdapter):
|
|
adapter.config.adapter.provider = ["HomeAssistant"] # NodeRED disabled
|
|
await adapter.update_data(force_enable=False)
|
|
mock_post.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("requests.post")
|
|
async def test_update_data_force_enable_overrides_disabled(self, mock_post, adapter: NodeREDAdapter):
|
|
adapter.config.adapter.provider = ["HomeAssistant"]
|
|
adapter.ems.stage.return_value = EnergyManagementStage.CONTROL_DISPATCH
|
|
mock_post.return_value.status_code = 200
|
|
mock_post.return_value.json.return_value = {}
|
|
|
|
await adapter.update_data(force_enable=True)
|
|
|
|
mock_post.assert_called_once()
|