odoo_mqtt/iot_bridge/tests/unit/test_parser_registry.py
matthias.lotz ea565775b2 feat: parser_config als Wire-Format durchgehend (Phase 3 komplett)
session_config wird nicht mehr über die API/YAML gesendet.
parser_config ist jetzt das einzige Config-Format zwischen Odoo und Bridge.

Änderungen:
- api/models.py: DeviceConfig.session_config → parser_config: dict
  SessionConfig bleibt als internes Modell für SessionDetector
  DeviceConfig.to_session_config() extrahiert Werte mit Defaults
- config/schema.py: DeviceConfig gleich umgestellt + to_session_config()
- config/loader.py: liest parser_config aus YAML, Fallback für legacy
  session_config (Rückwärtskompatibilität für bestehende config-active.yaml)
- core/device_manager.py: device.session_config → device.to_session_config()
- core/service_manager.py: session_config Referenzen entfernt
- Odoo _build_bridge_config: sendet parser_config direkt (+ heartbeat)
- Odoo iot_api.py: gleich umgestellt
- Tests: alle SessionConfig-Fixtures → parser_config dicts
  63/63 passing
2026-03-11 13:05:54 +01:00

199 lines
7.7 KiB
Python

"""Unit tests for the Parser Registry."""
import pytest
from parsers.registry import PARSER_REGISTRY, get_parser, get_schema, list_parser_types
from parsers.shelly_parser import ShellyParser
# ─────────────────────────────────────────────────────────────────────────────
# get_parser
# ─────────────────────────────────────────────────────────────────────────────
def test_get_parser_shelly_pm_returns_shelly_parser_instance() -> None:
parser = get_parser("shelly_pm")
assert isinstance(parser, ShellyParser)
def test_get_parser_returns_new_instance_each_call() -> None:
p1 = get_parser("shelly_pm")
p2 = get_parser("shelly_pm")
assert p1 is not p2
def test_get_parser_unknown_type_raises_key_error() -> None:
with pytest.raises(KeyError, match="Unknown parser type"):
get_parser("does_not_exist")
def test_get_parser_error_message_contains_available_types() -> None:
with pytest.raises(KeyError) as exc_info:
get_parser("unknown_xyz")
assert "shelly_pm" in str(exc_info.value)
# ─────────────────────────────────────────────────────────────────────────────
# get_schema
# ─────────────────────────────────────────────────────────────────────────────
def test_get_schema_returns_dict() -> None:
schema = get_schema()
assert isinstance(schema, dict)
def test_get_schema_contains_shelly_pm() -> None:
schema = get_schema()
assert "shelly_pm" in schema
def test_get_schema_shelly_pm_has_required_fields() -> None:
entry = get_schema()["shelly_pm"]
assert "label" in entry
assert "description" in entry
assert "topic_hint" in entry
assert "data_unit" in entry
assert "parameters" in entry
def test_get_schema_does_not_expose_class() -> None:
"""The 'class' key must not leak into the public schema."""
schema = get_schema()
for entry in schema.values():
assert "class" not in entry
def test_get_schema_parameters_are_list() -> None:
schema = get_schema()
for entry in schema.values():
assert isinstance(entry["parameters"], list)
def test_get_schema_shelly_pm_parameters_have_required_keys() -> None:
params = get_schema()["shelly_pm"]["parameters"]
assert len(params) > 0
for param in params:
assert "name" in param
assert "label" in param
assert "type" in param
assert "default" in param
# ─────────────────────────────────────────────────────────────────────────────
# list_parser_types
# ─────────────────────────────────────────────────────────────────────────────
def test_list_parser_types_returns_sorted_list() -> None:
types = list_parser_types()
assert isinstance(types, list)
assert types == sorted(types)
def test_list_parser_types_contains_shelly_pm() -> None:
assert "shelly_pm" in list_parser_types()
# ─────────────────────────────────────────────────────────────────────────────
# PARSER_REGISTRY integrity
# ─────────────────────────────────────────────────────────────────────────────
def test_registry_all_entries_have_class() -> None:
for key, entry in PARSER_REGISTRY.items():
assert "class" in entry, f"Entry {key!r} missing 'class'"
def test_registry_all_classes_have_parse_message() -> None:
for key, entry in PARSER_REGISTRY.items():
cls = entry["class"]
assert hasattr(cls, "parse_message"), (
f"Parser class for {key!r} missing parse_message()"
)
def test_registry_all_classes_are_instantiable() -> None:
for key, entry in PARSER_REGISTRY.items():
cls = entry["class"]
instance = cls()
assert instance is not None
# ─────────────────────────────────────────────────────────────────────────────
# Integration: DeviceManager uses registry per device
# ─────────────────────────────────────────────────────────────────────────────
def test_device_manager_creates_per_device_parser() -> None:
"""DeviceManager must store an independent parser per device_id."""
from unittest.mock import Mock
from api.models import BridgeConfig, DeviceConfig
from core.device_manager import DeviceManager
mqtt_client = Mock()
event_queue = Mock()
manager = DeviceManager(mqtt_client=mqtt_client, event_queue=event_queue)
_cfg = {"standby_threshold_w": 20, "working_threshold_w": 100,
"start_debounce_s": 3, "stop_debounce_s": 15,
"message_timeout_s": 20, "heartbeat_interval_s": 60}
config = BridgeConfig(
mqtt=None,
devices=[
DeviceConfig(
device_id="dev-a",
machine_name="Machine A",
mqtt_topic="dev-a/status/pm1:0",
parser_type="shelly_pm",
device_status_timeout_s=120,
parser_config=_cfg,
),
DeviceConfig(
device_id="dev-b",
machine_name="Machine B",
mqtt_topic="dev-b/status/pm1:0",
parser_type="shelly_pm",
device_status_timeout_s=120,
parser_config=_cfg,
),
],
version="1.0",
)
manager.apply_config(config)
assert "dev-a" in manager.parsers
assert "dev-b" in manager.parsers
# Each device gets its own parser instance
assert manager.parsers["dev-a"] is not manager.parsers["dev-b"]
def test_device_manager_removes_parser_on_device_removal() -> None:
from unittest.mock import Mock
from api.models import BridgeConfig, DeviceConfig
from core.device_manager import DeviceManager
mqtt_client = Mock()
event_queue = Mock()
manager = DeviceManager(mqtt_client=mqtt_client, event_queue=event_queue)
device = DeviceConfig(
device_id="dev-x",
machine_name="Machine X",
mqtt_topic="dev-x/status/pm1:0",
parser_type="shelly_pm",
device_status_timeout_s=120,
parser_config={"standby_threshold_w": 20, "working_threshold_w": 100,
"start_debounce_s": 3, "stop_debounce_s": 15,
"message_timeout_s": 20, "heartbeat_interval_s": 60},
)
manager.apply_config(BridgeConfig(mqtt=None, devices=[device], version="1.0"))
assert "dev-x" in manager.parsers
manager.apply_config(BridgeConfig(mqtt=None, devices=[], version="1.0"))
assert "dev-x" not in manager.parsers