Add tests for legacy/Ecowitt protocol conflict

Add comprehensive tests (tests/test_conflicts.py) that verify
effective_protocols, protocols_conflict, issue registry updates,
options flow guards, and route wiring to prevent running legacy and
Ecowitt concurrently. Update existing tests to account for the
LEGACY_ENABLED flag in Ecowitt-only setups.
ecowitt_support
SchiZzA 2026-07-25 21:22:36 +02:00
parent 2f30e161a6
commit 8b38db6951
No known key found for this signature in database
3 changed files with 240 additions and 2 deletions

View File

@ -357,6 +357,8 @@ async def test_options_flow_ecowitt_uses_get_url_placeholders_and_webhook_defaul
options={
ECOWITT_WEBHOOK_ID: "",
ECOWITT_ENABLED: False,
# Ecowitt can only be turned on when the legacy endpoint is off.
LEGACY_ENABLED: False,
},
)
entry.add_to_hass(hass)

234
tests/test_conflicts.py Normal file
View File

@ -0,0 +1,234 @@
"""Legacy (PWS/WSLink) and Ecowitt must never run at the same time.
Both endpoints remap onto the same internal sensor keys and push through the same
coordinator, so running both mixes measurement units, blanks the keys the other protocol
does not report, and feeds one entity from two sources.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.sws12500.conflicts import (
ERROR_MUTUALLY_EXCLUSIVE,
effective_protocols,
protocols_conflict,
update_protocol_conflict_issue,
)
from custom_components.sws12500.const import (
API_ID,
API_KEY,
DOMAIN,
ECOWITT_ENABLED,
ECOWITT_WEBHOOK_ID,
LEGACY_ENABLED,
WSLINK,
)
from homeassistant.helpers import issue_registry as ir
def _entry(**options: Any) -> Any:
return SimpleNamespace(options=options, entry_id="entry123")
# ---------------------------------------------------------------------------
# effective_protocols / protocols_conflict
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("options", "expected"),
[
# LEGACY_ENABLED defaults to True, ECOWITT_ENABLED to False.
({}, (True, False)),
({LEGACY_ENABLED: True, ECOWITT_ENABLED: False}, (True, False)),
({LEGACY_ENABLED: False, ECOWITT_ENABLED: True}, (False, True)),
({LEGACY_ENABLED: False, ECOWITT_ENABLED: False}, (False, False)),
# Conflict: legacy wins, Ecowitt is dropped rather than corrupting entities.
({LEGACY_ENABLED: True, ECOWITT_ENABLED: True}, (True, False)),
# A bare ECOWITT_ENABLED still conflicts, because legacy defaults to on.
({ECOWITT_ENABLED: True}, (True, False)),
],
)
def test_effective_protocols(options, expected) -> None:
assert effective_protocols(_entry(**options)) == expected
@pytest.mark.parametrize(
("options", "conflict"),
[
({}, False),
({LEGACY_ENABLED: False, ECOWITT_ENABLED: True}, False),
({LEGACY_ENABLED: True, ECOWITT_ENABLED: True}, True),
({ECOWITT_ENABLED: True}, True),
],
)
def test_protocols_conflict(options, conflict) -> None:
assert protocols_conflict(_entry(**options)) is conflict
# ---------------------------------------------------------------------------
# Repairs issue
# ---------------------------------------------------------------------------
async def test_conflict_issue_created_and_cleared(hass) -> None:
"""The issue appears for a conflicting entry and disappears once resolved."""
registry = ir.async_get(hass)
entry = _entry(**{LEGACY_ENABLED: True, ECOWITT_ENABLED: True})
update_protocol_conflict_issue(hass, entry)
issue = registry.async_get_issue(DOMAIN, f"protocol_conflict_{entry.entry_id}")
assert issue is not None
assert issue.severity == ir.IssueSeverity.ERROR
assert issue.translation_key == "protocol_conflict"
entry.options = {LEGACY_ENABLED: True, ECOWITT_ENABLED: False}
update_protocol_conflict_issue(hass, entry)
assert registry.async_get_issue(DOMAIN, f"protocol_conflict_{entry.entry_id}") is None
async def test_no_issue_for_clean_entry(hass) -> None:
"""A single-protocol entry raises nothing."""
registry = ir.async_get(hass)
entry = _entry(**{LEGACY_ENABLED: False, ECOWITT_ENABLED: True})
update_protocol_conflict_issue(hass, entry)
assert registry.async_get_issue(DOMAIN, f"protocol_conflict_{entry.entry_id}") is None
# ---------------------------------------------------------------------------
# Options flow guards
# ---------------------------------------------------------------------------
async def test_options_basic_rejects_enabling_legacy_while_ecowitt_on(hass, enable_custom_integrations) -> None:
"""Turning the legacy endpoint on while Ecowitt is active is refused."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={LEGACY_ENABLED: False, ECOWITT_ENABLED: True, ECOWITT_WEBHOOK_ID: "abc"},
)
entry.add_to_hass(hass)
init = await hass.config_entries.options.async_init(entry.entry_id)
await hass.config_entries.options.async_configure(init["flow_id"], user_input={"next_step_id": "basic"})
result = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={
API_ID: "station",
API_KEY: "secret",
WSLINK: False,
LEGACY_ENABLED: True,
},
)
assert result["type"] == "form"
assert result["errors"] == {"base": ERROR_MUTUALLY_EXCLUSIVE}
# Nothing was written.
assert entry.options[ECOWITT_ENABLED] is True
assert entry.options[LEGACY_ENABLED] is False
async def test_options_ecowitt_rejects_enabling_while_legacy_on(hass, enable_custom_integrations) -> None:
"""Turning Ecowitt on while the legacy endpoint is active is refused."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={LEGACY_ENABLED: True, ECOWITT_ENABLED: False, API_ID: "a", API_KEY: "b"},
)
entry.add_to_hass(hass)
init = await hass.config_entries.options.async_init(entry.entry_id)
form = await hass.config_entries.options.async_configure(
init["flow_id"], user_input={"next_step_id": "ecowitt"}
)
webhook = (form.get("description_placeholders") or {})["webhook_id"]
result = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={ECOWITT_WEBHOOK_ID: webhook, ECOWITT_ENABLED: True},
)
assert result["type"] == "form"
assert result["step_id"] == "ecowitt"
assert result["errors"] == {"base": ERROR_MUTUALLY_EXCLUSIVE}
assert entry.options[ECOWITT_ENABLED] is False
async def test_options_ecowitt_allows_saving_while_disabled(hass, enable_custom_integrations) -> None:
"""The guard only blocks *enabling* it - editing the webhook id stays possible."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={LEGACY_ENABLED: True, ECOWITT_ENABLED: False, API_ID: "a", API_KEY: "b"},
)
entry.add_to_hass(hass)
init = await hass.config_entries.options.async_init(entry.entry_id)
await hass.config_entries.options.async_configure(init["flow_id"], user_input={"next_step_id": "ecowitt"})
result = await hass.config_entries.options.async_configure(
init["flow_id"],
user_input={ECOWITT_WEBHOOK_ID: "deadbeef", ECOWITT_ENABLED: False},
)
assert result["type"] == "create_entry"
assert result["data"][ECOWITT_WEBHOOK_ID] == "deadbeef"
# ---------------------------------------------------------------------------
# Route wiring
# ---------------------------------------------------------------------------
async def test_conflicting_entry_does_not_wire_ecowitt_route(hass, enable_custom_integrations) -> None:
"""A pre-existing both-enabled config must not ingest Ecowitt payloads.
Without this the Ecowitt handler keeps writing imperial values into the same
coordinator as the legacy endpoint, mislabelling units and blanking the keys the
other protocol reports.
"""
class _Router:
def add_get(self, path: str, handler: Any, **_kw: Any) -> Any:
return SimpleNamespace(method="GET")
def add_post(self, path: str, handler: Any, **_kw: Any) -> Any:
return SimpleNamespace(method="POST")
hass.http = SimpleNamespace(app=SimpleNamespace(router=_Router()))
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={
LEGACY_ENABLED: True,
ECOWITT_ENABLED: True,
ECOWITT_WEBHOOK_ID: "abc",
API_ID: "a",
API_KEY: "b",
},
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
routes = hass.data[DOMAIN]["routes"]
snapshot = routes.snapshot()
ecowitt = [r for r in snapshot.values() if r["path"].startswith("/weatherhub/")]
assert ecowitt, "ecowitt route should be registered"
assert all(not r["enabled"] for r in ecowitt), "ecowitt must be inactive while legacy is on"
# The legacy (WU) route is the one that stays live.
assert routes.path_enabled("/weatherstation/updateweatherstation.php")
# ... and the user is told about it.
assert ir.async_get(hass).async_get_issue(DOMAIN, f"protocol_conflict_{entry.entry_id}") is not None

View File

@ -20,7 +20,7 @@ from aioecowitt import EcoWittSensor, EcoWittSensorTypes
from aioecowitt.station import EcoWittStation
import pytest
from custom_components.sws12500.const import DOMAIN, ECOWITT_ENABLED, REMAP_ECOWITT_COMPAT
from custom_components.sws12500.const import DOMAIN, ECOWITT_ENABLED, LEGACY_ENABLED, REMAP_ECOWITT_COMPAT
from custom_components.sws12500.ecowitt import STYPE_TO_HA, EcowittBridge, EcoWittNativeSensor
# Default config stub for native entities: the integration shares a single device,
@ -28,8 +28,10 @@ from custom_components.sws12500.ecowitt import STYPE_TO_HA, EcowittBridge, EcoWi
# PWS config (no ecowitt/wslink flags) is enough for the non-device assertions.
_PWS_CONFIG = SimpleNamespace(options={})
# An ecowitt config that yields model "Ecowitt GW1000" for device-info assertions.
# An Ecowitt-only setup: the legacy endpoint must be off, otherwise the two protocols
# would feed the same entities (see conflicts.effective_protocols).
_ECOWITT_CONFIG = SimpleNamespace(
options={ECOWITT_ENABLED: True},
options={ECOWITT_ENABLED: True, LEGACY_ENABLED: False},
runtime_data=SimpleNamespace(ecowitt_model="GW1000"),
)