diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 00ab63b..46eb0d4 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -13,6 +13,7 @@ from custom_components.sws12500.const import ( ECOWITT_ENABLED, ECOWITT_WEBHOOK_ID, INVALID_CREDENTIALS, + LEGACY_ENABLED, POCASI_CZ_API_ID, POCASI_CZ_API_KEY, POCASI_CZ_ENABLED, @@ -36,9 +37,15 @@ async def test_config_flow_user_form_then_create_entry( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == "menu" assert result["step_id"] == "user" + form = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "pws"} + ) + assert form["type"] == "form" + assert form["step_id"] == "pws" + user_input = { API_ID: "my_id", API_KEY: "my_key", @@ -46,12 +53,16 @@ async def test_config_flow_user_form_then_create_entry( DEV_DBG: False, } result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=user_input + form["flow_id"], user_input=user_input ) assert result2["type"] == "create_entry" assert result2["title"] == DOMAIN - assert result2["data"] == user_input - assert result2["options"] == user_input + # The PWS step augments user input with legacy/ecowitt flags. + assert result2["data"][API_ID] == "my_id" + assert result2["data"][API_KEY] == "my_key" + assert result2["data"][LEGACY_ENABLED] is True + assert result2["data"][ECOWITT_ENABLED] is False + assert result2["options"] == result2["data"] @pytest.mark.asyncio @@ -62,7 +73,13 @@ async def test_config_flow_user_invalid_credentials_api_id( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == "menu" + + form = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "pws"} + ) + assert form["type"] == "form" + assert form["step_id"] == "pws" user_input = { API_ID: INVALID_CREDENTIALS[0], @@ -71,10 +88,10 @@ async def test_config_flow_user_invalid_credentials_api_id( DEV_DBG: False, } result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=user_input + form["flow_id"], user_input=user_input ) assert result2["type"] == "form" - assert result2["step_id"] == "user" + assert result2["step_id"] == "pws" assert result2["errors"][API_ID] == "valid_credentials_api" @@ -86,7 +103,13 @@ async def test_config_flow_user_invalid_credentials_api_key( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == "menu" + + form = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "pws"} + ) + assert form["type"] == "form" + assert form["step_id"] == "pws" user_input = { API_ID: "ok_id", @@ -95,10 +118,10 @@ async def test_config_flow_user_invalid_credentials_api_key( DEV_DBG: False, } result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=user_input + form["flow_id"], user_input=user_input ) assert result2["type"] == "form" - assert result2["step_id"] == "user" + assert result2["step_id"] == "pws" assert result2["errors"][API_KEY] == "valid_credentials_key" @@ -110,7 +133,13 @@ async def test_config_flow_user_invalid_credentials_match( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == "menu" + + form = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "pws"} + ) + assert form["type"] == "form" + assert form["step_id"] == "pws" user_input = { API_ID: "same", @@ -119,10 +148,10 @@ async def test_config_flow_user_invalid_credentials_match( DEV_DBG: False, } result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=user_input + form["flow_id"], user_input=user_input ) assert result2["type"] == "form" - assert result2["step_id"] == "user" + assert result2["step_id"] == "pws" assert result2["errors"]["base"] == "valid_credentials_match" @@ -135,7 +164,7 @@ async def test_options_flow_init_menu(hass, enable_custom_integrations) -> None: result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] == "menu" assert result["step_id"] == "init" - assert set(result["menu_options"]) == {"basic", "ecowitt", "windy", "pocasi"} + assert set(result["menu_options"]) == {"basic", "wslink_port_setup", "ecowitt", "windy", "pocasi"} @pytest.mark.asyncio diff --git a/tests/test_data.py b/tests/test_data.py index 8ad6cac..7f373af 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -1,13 +1,46 @@ -from custom_components.sws12500.data import ( - ENTRY_ADD_ENTITIES, - ENTRY_COORDINATOR, - ENTRY_DESCRIPTIONS, - ENTRY_LAST_OPTIONS, -) +"""Tests for the typed per-entry runtime data container.""" + +from __future__ import annotations + +from custom_components.sws12500.data import SWSRuntimeData -def test_data_constants(): - assert ENTRY_COORDINATOR == "coordinator" - assert ENTRY_ADD_ENTITIES == "async_add_entities" - assert ENTRY_DESCRIPTIONS == "sensor_descriptions" - assert ENTRY_LAST_OPTIONS == "last_options" +def test_runtime_data_defaults(): + """SWSRuntimeData exposes the expected fields with safe defaults. + + The ad-hoc hass.data[DOMAIN][entry_id] string keys (ENTRY_*) were replaced by + this typed dataclass stored on entry.runtime_data in v2.0. + """ + runtime = SWSRuntimeData( + coordinator=object(), # type: ignore[arg-type] + health_coordinator=object(), # type: ignore[arg-type] + last_options={"legacy_enabled": True}, + ) + + assert runtime.last_options == {"legacy_enabled": True} + + # Optional platform wiring defaults. + assert runtime.add_sensor_entities is None + assert runtime.sensor_descriptions == {} + assert runtime.add_binary_entities is None + assert runtime.binary_descriptions == {} + assert runtime.added_binary_keys == set() + + # Diagnostics / staleness defaults. + assert runtime.health_data is None + assert runtime.last_seen == {} + assert runtime.started_at is not None + + +def test_runtime_data_collections_are_independent_per_instance(): + """Mutable defaults must not be shared between instances.""" + a = SWSRuntimeData(coordinator=object(), health_coordinator=object(), last_options={}) # type: ignore[arg-type] + b = SWSRuntimeData(coordinator=object(), health_coordinator=object(), last_options={}) # type: ignore[arg-type] + + a.sensor_descriptions["x"] = object() # type: ignore[assignment] + a.added_binary_keys.add("y") + a.last_seen["z"] = object() # type: ignore[assignment] + + assert b.sensor_descriptions == {} + assert b.added_binary_keys == set() + assert b.last_seen == {} diff --git a/tests/test_init.py b/tests/test_init.py index baf59d2..fc6cbf2 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -22,6 +22,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.sws12500 import WeatherDataUpdateCoordinator, async_setup_entry from custom_components.sws12500.const import DOMAIN +from custom_components.sws12500.data import SWSRuntimeData @pytest.fixture @@ -43,6 +44,14 @@ async def test_async_setup_entry_creates_runtime_state( lambda _hass, _coordinator, _coordinator_h, _entry: True, ) + # Calling async_setup_entry directly leaves the entry in NOT_LOADED state, so the + # health coordinator's first refresh (which requires SETUP_IN_PROGRESS and does + # network I/O) is mocked out to keep this test focused on setup wiring. + monkeypatch.setattr( + "custom_components.sws12500.HealthCoordinator.async_config_entry_first_refresh", + AsyncMock(return_value=None), + ) + # Avoid depending on Home Assistant integration loader in this test. # This keeps the test focused on our integration's setup behavior. monkeypatch.setattr( @@ -54,9 +63,12 @@ async def test_async_setup_entry_creates_runtime_state( result = await async_setup_entry(hass, config_entry) assert result is True + # Per-entry state now lives on entry.runtime_data (SWSRuntimeData), not in + # hass.data[DOMAIN][entry_id]. hass.data[DOMAIN] only holds shared route state. assert DOMAIN in hass.data - assert config_entry.entry_id in hass.data[DOMAIN] - assert isinstance(hass.data[DOMAIN][config_entry.entry_id], dict) + assert isinstance(config_entry.runtime_data, SWSRuntimeData) + assert config_entry.runtime_data.coordinator is not None + assert config_entry.runtime_data.health_coordinator is not None async def test_async_setup_entry_forwards_sensor_platform( @@ -72,6 +84,14 @@ async def test_async_setup_entry_forwards_sensor_platform( lambda _hass, _coordinator, _coordinator_h, _entry: True, ) + # Calling async_setup_entry directly leaves the entry in NOT_LOADED state, so the + # health coordinator's first refresh (which requires SETUP_IN_PROGRESS and does + # network I/O) is mocked out to keep this test focused on setup wiring. + monkeypatch.setattr( + "custom_components.sws12500.HealthCoordinator.async_config_entry_first_refresh", + AsyncMock(return_value=None), + ) + # Patch forwarding so we don't need to load real platforms for this unit/integration test. hass.config_entries.async_forward_entry_setups = AsyncMock(return_value=True) diff --git a/tests/test_integration_lifecycle.py b/tests/test_integration_lifecycle.py index 439bb6a..a23044d 100644 --- a/tests/test_integration_lifecycle.py +++ b/tests/test_integration_lifecycle.py @@ -9,26 +9,23 @@ from aiohttp.web_exceptions import HTTPUnauthorized import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.sws12500 import ( - HealthCoordinator, - IncorrectDataError, - WeatherDataUpdateCoordinator, - async_setup_entry, - async_unload_entry, - register_path, - update_listener, -) +from custom_components.sws12500 import async_setup_entry, async_unload_entry, register_path, update_listener from custom_components.sws12500.const import ( API_ID, API_KEY, DEFAULT_URL, DOMAIN, + ECOWITT_URL_PREFIX, HEALTH_URL, SENSORS_TO_LOAD, WSLINK, WSLINK_URL, ) -from custom_components.sws12500.data import ENTRY_COORDINATOR, ENTRY_LAST_OPTIONS +from custom_components.sws12500.coordinator import IncorrectDataError, WeatherDataUpdateCoordinator +from custom_components.sws12500.data import SWSRuntimeData +from custom_components.sws12500.health_coordinator import HealthCoordinator + +ECOWITT_PATH = ECOWITT_URL_PREFIX + "/{webhook_id}" @dataclass(slots=True) @@ -70,16 +67,27 @@ def hass_with_http(hass): return hass +def _mock_health_first_refresh(monkeypatch) -> None: + """Calling async_setup_entry directly leaves the entry NOT_LOADED. + + The health coordinator's first refresh requires SETUP_IN_PROGRESS and does network + I/O, so we mock it out to keep these lifecycle tests focused on wiring. + """ + monkeypatch.setattr( + "custom_components.sws12500.HealthCoordinator.async_config_entry_first_refresh", + AsyncMock(return_value=None), + ) + + +# --- register_path --------------------------------------------------------- + + @pytest.mark.asyncio async def test_register_path_registers_routes_and_stores_dispatcher(hass_with_http): entry = MockConfigEntry( domain=DOMAIN, data={}, - options={ - API_ID: "id", - API_KEY: "key", - WSLINK: False, - }, + options={API_ID: "id", API_KEY: "key", WSLINK: False}, ) entry.add_to_hass(hass_with_http) @@ -89,21 +97,19 @@ async def test_register_path_registers_routes_and_stores_dispatcher(hass_with_ht ok = register_path(hass_with_http, coordinator, coordinator_health, entry) assert ok is True - # Router registrations + # Router registrations: GET for legacy/wslink/health, POST for wslink + ecowitt. router: _RouterStub = hass_with_http.http.app.router assert [p for (p, _h) in router.add_get_calls] == [ DEFAULT_URL, WSLINK_URL, HEALTH_URL, ] - assert [p for (p, _h) in router.add_post_calls] == [WSLINK_URL] + assert [p for (p, _h) in router.add_post_calls] == [WSLINK_URL, ECOWITT_PATH] - # Dispatcher stored + # Dispatcher stored under the shared (cross-reload) hass.data[DOMAIN]. assert DOMAIN in hass_with_http.data - assert "routes" in hass_with_http.data[DOMAIN] - routes = hass_with_http.data[DOMAIN]["routes"] + routes = hass_with_http.data[DOMAIN].get("routes") assert routes is not None - # show_enabled() should return a string assert isinstance(routes.show_enabled(), str) @@ -116,18 +122,13 @@ async def test_register_path_raises_config_entry_not_ready_on_router_runtime_err entry = MockConfigEntry( domain=DOMAIN, data={}, - options={ - API_ID: "id", - API_KEY: "key", - WSLINK: False, - }, + options={API_ID: "id", API_KEY: "key", WSLINK: False}, ) entry.add_to_hass(hass_with_http) coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry) coordinator_health = HealthCoordinator(hass_with_http, entry) - # Make router raise RuntimeError on add router: _RouterStub = hass_with_http.http.app.router router.raise_on_add = RuntimeError("router broken") @@ -145,26 +146,24 @@ async def test_register_path_checked_hass_data_wrong_type_raises_config_entry_no entry = MockConfigEntry( domain=DOMAIN, data={}, - options={ - API_ID: "id", - API_KEY: "key", - WSLINK: False, - }, + options={API_ID: "id", API_KEY: "key", WSLINK: False}, ) entry.add_to_hass(hass_with_http) coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry) coordinator_health = HealthCoordinator(hass_with_http, entry) - # Force wrong type under DOMAIN so `checked(..., dict)` fails. - hass_with_http.data[DOMAIN] = [] + hass_with_http.data[DOMAIN] = [] # wrong type -> checked(..., dict) fails with pytest.raises(ConfigEntryNotReady): register_path(hass_with_http, coordinator, coordinator_health, entry) +# --- async_setup_entry ----------------------------------------------------- + + @pytest.mark.asyncio -async def test_async_setup_entry_creates_entry_dict_and_coordinator_and_forwards_platforms( +async def test_async_setup_entry_creates_runtime_data_and_forwards_platforms( hass_with_http, monkeypatch, ): @@ -175,7 +174,7 @@ async def test_async_setup_entry_creates_entry_dict_and_coordinator_and_forwards ) entry.add_to_hass(hass_with_http) - # Avoid loading actual platforms via HA loader. + _mock_health_first_refresh(monkeypatch) monkeypatch.setattr( hass_with_http.config_entries, "async_forward_entry_setups", @@ -185,17 +184,14 @@ async def test_async_setup_entry_creates_entry_dict_and_coordinator_and_forwards ok = await async_setup_entry(hass_with_http, entry) assert ok is True - # Runtime storage exists and is a dict - assert DOMAIN in hass_with_http.data - assert entry.entry_id in hass_with_http.data[DOMAIN] - entry_data = hass_with_http.data[DOMAIN][entry.entry_id] - assert isinstance(entry_data, dict) + # Per-entry state now lives on entry.runtime_data (SWSRuntimeData). + assert isinstance(entry.runtime_data, SWSRuntimeData) + assert isinstance(entry.runtime_data.coordinator, WeatherDataUpdateCoordinator) + assert isinstance(entry.runtime_data.last_options, dict) - # Coordinator stored and last options snapshot stored - assert isinstance(entry_data.get(ENTRY_COORDINATOR), WeatherDataUpdateCoordinator) - assert isinstance(entry_data.get(ENTRY_LAST_OPTIONS), dict) + # Shared dispatcher registered under hass.data[DOMAIN]. + assert "routes" in hass_with_http.data[DOMAIN] - # Forwarded setups invoked hass_with_http.config_entries.async_forward_entry_setups.assert_awaited() @@ -203,12 +199,7 @@ async def test_async_setup_entry_creates_entry_dict_and_coordinator_and_forwards async def test_async_setup_entry_fatal_when_register_path_returns_false( hass_with_http, monkeypatch ): - """Cover the fatal branch when `register_path` returns False. - - async_setup_entry does: - routes_enabled = register_path(...) - if not routes_enabled: raise PlatformNotReady - """ + """Cover the fatal branch when `register_path` returns False -> PlatformNotReady.""" from homeassistant.exceptions import PlatformNotReady entry = MockConfigEntry( @@ -218,17 +209,14 @@ async def test_async_setup_entry_fatal_when_register_path_returns_false( ) entry.add_to_hass(hass_with_http) - # Ensure there are no pre-registered routes so async_setup_entry calls register_path. + # No pre-registered routes -> async_setup_entry calls register_path. hass_with_http.data.setdefault(DOMAIN, {}) hass_with_http.data[DOMAIN].pop("routes", None) - # Force register_path to return False monkeypatch.setattr( "custom_components.sws12500.register_path", lambda _hass, _coordinator, _coordinator_h, _entry: False, ) - - # Forwarding shouldn't be reached; patch anyway to avoid accidental loader calls. monkeypatch.setattr( hass_with_http.config_entries, "async_forward_entry_setups", @@ -240,10 +228,11 @@ async def test_async_setup_entry_fatal_when_register_path_returns_false( @pytest.mark.asyncio -async def test_async_setup_entry_reuses_existing_coordinator_and_switches_routes( +async def test_async_setup_entry_reuses_route_dispatcher_and_switches_protocol( hass_with_http, monkeypatch, ): + """On reload the shared route dispatcher is reused; the coordinator is recreated.""" entry = MockConfigEntry( domain=DOMAIN, data={}, @@ -251,29 +240,19 @@ async def test_async_setup_entry_reuses_existing_coordinator_and_switches_routes ) entry.add_to_hass(hass_with_http) - # Pretend setup already happened and a coordinator exists - hass_with_http.data.setdefault(DOMAIN, {}) - existing_coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry) - hass_with_http.data[DOMAIN][entry.entry_id] = { - ENTRY_COORDINATOR: existing_coordinator, - ENTRY_LAST_OPTIONS: dict(entry.options), - } + # Pre-register routes once (legacy/WU active). + initial_coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry) + initial_health = HealthCoordinator(hass_with_http, entry) + register_path(hass_with_http, initial_coordinator, initial_health, entry) + routes_before = hass_with_http.data[DOMAIN]["routes"] + assert routes_before.path_enabled(DEFAULT_URL) is True - # Provide pre-registered routes dispatcher - routes = hass_with_http.data[DOMAIN].get("routes") - if routes is None: - # Create a dispatcher via register_path once - coordinator_health = HealthCoordinator(hass_with_http, entry) - register_path(hass_with_http, existing_coordinator, coordinator_health, entry) - routes = hass_with_http.data[DOMAIN]["routes"] - - # Turn on WSLINK to trigger dispatcher switching. - # ConfigEntry.options cannot be changed directly; use async_update_entry. + # Switch to WSLink and run setup again. hass_with_http.config_entries.async_update_entry( entry, options={**dict(entry.options), WSLINK: True} ) - # Avoid loading actual platforms via HA loader. + _mock_health_first_refresh(monkeypatch) monkeypatch.setattr( hass_with_http.config_entries, "async_forward_entry_setups", @@ -283,34 +262,42 @@ async def test_async_setup_entry_reuses_existing_coordinator_and_switches_routes ok = await async_setup_entry(hass_with_http, entry) assert ok is True - # Coordinator reused (same object) - entry_data = hass_with_http.data[DOMAIN][entry.entry_id] - assert entry_data[ENTRY_COORDINATOR] is existing_coordinator + # Same dispatcher object reused (survives across reloads). + assert hass_with_http.data[DOMAIN]["routes"] is routes_before + # Protocol switched to WSLink. + assert routes_before.path_enabled(WSLINK_URL) is True + assert routes_before.path_enabled(DEFAULT_URL) is False + # A fresh coordinator is wired onto entry.runtime_data. + assert isinstance(entry.runtime_data, SWSRuntimeData) + assert isinstance(entry.runtime_data.coordinator, WeatherDataUpdateCoordinator) + + +# --- update_listener ------------------------------------------------------- + + +def _entry_with_runtime(hass, *, options: dict[str, Any]) -> MockConfigEntry: + entry = MockConfigEntry(domain=DOMAIN, data={}, options=options) + entry.add_to_hass(hass) + entry.runtime_data = SWSRuntimeData( + coordinator=object(), # type: ignore[arg-type] + health_coordinator=object(), # type: ignore[arg-type] + last_options=dict(options), + ) + return entry @pytest.mark.asyncio async def test_update_listener_skips_reload_when_only_sensors_to_load_changes( hass_with_http, ): - entry = MockConfigEntry( - domain=DOMAIN, - data={}, + entry = _entry_with_runtime( + hass_with_http, options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"]}, ) - entry.add_to_hass(hass_with_http) - - # Seed hass.data snapshot - hass_with_http.data.setdefault(DOMAIN, {}) - hass_with_http.data[DOMAIN][entry.entry_id] = { - # Seed the full old options snapshot. If we only store SENSORS_TO_LOAD here, - # update_listener will detect differences for other keys (e.g. auth keys) and reload. - ENTRY_LAST_OPTIONS: dict(entry.options), - } hass_with_http.config_entries.async_reload = AsyncMock() # Only SENSORS_TO_LOAD changes. - # ConfigEntry.options cannot be changed directly; use async_update_entry. hass_with_http.config_entries.async_update_entry( entry, options={**dict(entry.options), SENSORS_TO_LOAD: ["a", "b"]} ) @@ -318,9 +305,8 @@ async def test_update_listener_skips_reload_when_only_sensors_to_load_changes( await update_listener(hass_with_http, entry) hass_with_http.config_entries.async_reload.assert_not_awaited() - # Snapshot should be updated - entry_data = hass_with_http.data[DOMAIN][entry.entry_id] - assert entry_data[ENTRY_LAST_OPTIONS] == dict(entry.options) + # The snapshot on runtime_data is refreshed. + assert entry.runtime_data.last_options == dict(entry.options) @pytest.mark.asyncio @@ -328,22 +314,13 @@ async def test_update_listener_triggers_reload_when_other_option_changes( hass_with_http, monkeypatch, ): - entry = MockConfigEntry( - domain=DOMAIN, - data={}, + entry = _entry_with_runtime( + hass_with_http, options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"], WSLINK: False}, ) - entry.add_to_hass(hass_with_http) - - hass_with_http.data.setdefault(DOMAIN, {}) - hass_with_http.data[DOMAIN][entry.entry_id] = { - ENTRY_LAST_OPTIONS: dict(entry.options), - } hass_with_http.config_entries.async_reload = AsyncMock(return_value=True) - # Change a different option. - # ConfigEntry.options cannot be changed directly; use async_update_entry. hass_with_http.config_entries.async_update_entry( entry, options={**dict(entry.options), WSLINK: True} ) @@ -358,76 +335,58 @@ async def test_update_listener_triggers_reload_when_other_option_changes( @pytest.mark.asyncio -async def test_update_listener_missing_snapshot_stores_current_options_then_reloads( - hass_with_http, -): - """Cover update_listener branch where the options snapshot is missing/invalid. - - This hits: - entry_data[ENTRY_LAST_OPTIONS] = dict(entry.options) - and then proceeds to reload. - """ +async def test_update_listener_without_runtime_snapshot_reloads(hass_with_http): + """When runtime_data is not a valid snapshot, update_listener reloads.""" entry = MockConfigEntry( domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"], WSLINK: False}, ) entry.add_to_hass(hass_with_http) - - hass_with_http.data.setdefault(DOMAIN, {}) - # Store an invalid snapshot type to force the "No/invalid snapshot" branch. - hass_with_http.data[DOMAIN][entry.entry_id] = {ENTRY_LAST_OPTIONS: "invalid"} + # Not an SWSRuntimeData instance -> the skip-reload fast path is bypassed. + entry.runtime_data = "invalid" hass_with_http.config_entries.async_reload = AsyncMock(return_value=True) await update_listener(hass_with_http, entry) - entry_data = hass_with_http.data[DOMAIN][entry.entry_id] - assert entry_data[ENTRY_LAST_OPTIONS] == dict(entry.options) hass_with_http.config_entries.async_reload.assert_awaited_once_with(entry.entry_id) -@pytest.mark.asyncio -async def test_async_unload_entry_pops_runtime_data_on_success(hass_with_http): - entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={API_ID: "id", API_KEY: "key"}, - ) - entry.add_to_hass(hass_with_http) +# --- async_unload_entry ---------------------------------------------------- - hass_with_http.data.setdefault(DOMAIN, {}) - hass_with_http.data[DOMAIN][entry.entry_id] = {ENTRY_COORDINATOR: object()} + +@pytest.mark.asyncio +async def test_async_unload_entry_returns_true_on_success(hass_with_http): + entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"}) + entry.add_to_hass(hass_with_http) hass_with_http.config_entries.async_unload_platforms = AsyncMock(return_value=True) ok = await async_unload_entry(hass_with_http, entry) + assert ok is True - assert entry.entry_id not in hass_with_http.data[DOMAIN] + hass_with_http.config_entries.async_unload_platforms.assert_awaited_once() @pytest.mark.asyncio -async def test_async_unload_entry_keeps_runtime_data_on_failure(hass_with_http): - entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={API_ID: "id", API_KEY: "key"}, - ) +async def test_async_unload_entry_returns_false_on_failure(hass_with_http): + entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"}) entry.add_to_hass(hass_with_http) - hass_with_http.data.setdefault(DOMAIN, {}) - hass_with_http.data[DOMAIN][entry.entry_id] = {ENTRY_COORDINATOR: object()} - hass_with_http.config_entries.async_unload_platforms = AsyncMock(return_value=False) ok = await async_unload_entry(hass_with_http, entry) + assert ok is False - assert entry.entry_id in hass_with_http.data[DOMAIN] + + +# --- coordinator auth (lifecycle-adjacent) --------------------------------- @pytest.mark.asyncio async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass): - """A few lifecycle-adjacent assertions to cover coordinator auth behavior in __init__.py.""" + """Cover coordinator auth behavior reachable from the webhook entrypoint.""" entry = MockConfigEntry( domain=DOMAIN, data={}, @@ -447,9 +406,7 @@ async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass): ) # type: ignore[arg-type] # Missing API_ID in options -> IncorrectDataError - entry2 = MockConfigEntry( - domain=DOMAIN, data={}, options={API_KEY: "key", WSLINK: False} - ) + entry2 = MockConfigEntry(domain=DOMAIN, data={}, options={API_KEY: "key", WSLINK: False}) entry2.add_to_hass(hass) coordinator2 = WeatherDataUpdateCoordinator(hass, entry2) with pytest.raises(IncorrectDataError): diff --git a/tests/test_received_data.py b/tests/test_received_data.py index 7ddc24b..720fd19 100644 --- a/tests/test_received_data.py +++ b/tests/test_received_data.py @@ -8,7 +8,6 @@ from unittest.mock import AsyncMock, MagicMock from aiohttp.web_exceptions import HTTPUnauthorized import pytest -from custom_components.sws12500 import IncorrectDataError, WeatherDataUpdateCoordinator from custom_components.sws12500.const import ( API_ID, API_KEY, @@ -20,6 +19,8 @@ from custom_components.sws12500.const import ( WSLINK, WSLINK_URL, ) +from custom_components.sws12500.coordinator import IncorrectDataError, WeatherDataUpdateCoordinator +from homeassistant.util import dt as dt_util @dataclass(slots=True) @@ -60,6 +61,18 @@ def _make_entry( entry = SimpleNamespace() entry.entry_id = "test_entry_id" entry.options = options + # DataUpdateCoordinator.__init__ calls config_entry.async_on_unload(...) when a + # config_entry is passed (see WeatherDataUpdateCoordinator.__init__). + entry.async_on_unload = lambda *_args, **_kwargs: None + # Per-entry runtime state lives on entry.runtime_data (SWSRuntimeData) since v2.0. + # received_data writes last_seen and reads health_coordinator / add_*_entities. + entry.runtime_data = SimpleNamespace( + health_coordinator=None, + add_sensor_entities=None, + add_binary_entities=None, + last_seen={}, + started_at=dt_util.utcnow(), + ) return entry @@ -135,13 +148,13 @@ async def test_received_data_success_remaps_and_updates_coordinator_data( # Patch remapping so this test doesn't depend on mapping tables. remapped = {"outside_temp": "10"} monkeypatch.setattr( - "custom_components.sws12500.remap_items", + "custom_components.sws12500.coordinator.remap_items", lambda _data: remapped, ) # Ensure no autodiscovery triggers monkeypatch.setattr( - "custom_components.sws12500.check_disabled", + "custom_components.sws12500.coordinator.check_disabled", lambda _remaped_items, _config: [], ) @@ -162,12 +175,12 @@ async def test_received_data_success_wslink_uses_wslink_remap(hass, monkeypatch) remapped = {"ws_temp": "1"} monkeypatch.setattr( - "custom_components.sws12500.remap_wslink_items", + "custom_components.sws12500.coordinator.remap_wslink_items", lambda _data: remapped, ) # If the wrong remapper is used, we'd crash because we won't patch it: monkeypatch.setattr( - "custom_components.sws12500.check_disabled", + "custom_components.sws12500.coordinator.check_disabled", lambda _remaped_items, _config: [], ) @@ -188,11 +201,11 @@ async def test_received_data_forwards_to_windy_when_enabled(hass, monkeypatch): coordinator.windy.push_data_to_windy = AsyncMock() monkeypatch.setattr( - "custom_components.sws12500.remap_items", + "custom_components.sws12500.coordinator.remap_items", lambda _data: {"k": "v"}, ) monkeypatch.setattr( - "custom_components.sws12500.check_disabled", + "custom_components.sws12500.coordinator.check_disabled", lambda _remaped_items, _config: [], ) @@ -216,11 +229,11 @@ async def test_received_data_forwards_to_pocasi_when_enabled(hass, monkeypatch): coordinator.pocasi.push_data_to_server = AsyncMock() monkeypatch.setattr( - "custom_components.sws12500.remap_wslink_items", + "custom_components.sws12500.coordinator.remap_wslink_items", lambda _data: {"k": "v"}, ) monkeypatch.setattr( - "custom_components.sws12500.check_disabled", + "custom_components.sws12500.coordinator.check_disabled", lambda _remaped_items, _config: [], ) @@ -246,35 +259,35 @@ async def test_received_data_autodiscovery_updates_options_notifies_and_adds_sen # Arrange: remapped payload contains keys that are disabled. remapped = {"a": "1", "b": "2"} - monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: remapped) + monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped) # Autodiscovery finds two sensors to add monkeypatch.setattr( - "custom_components.sws12500.check_disabled", + "custom_components.sws12500.coordinator.check_disabled", lambda _remaped_items, _config: ["a", "b"], ) # No previously loaded sensors - monkeypatch.setattr("custom_components.sws12500.loaded_sensors", lambda _c: []) + monkeypatch.setattr("custom_components.sws12500.coordinator.loaded_sensors", lambda _c: []) # translations returns a friendly name for each sensor key async def _translations(_hass, _domain, _key, **_kwargs): # return something non-None so it's included in human readable string return "Name" - monkeypatch.setattr("custom_components.sws12500.translations", _translations) + monkeypatch.setattr("custom_components.sws12500.coordinator.translations", _translations) translated_notification = AsyncMock() monkeypatch.setattr( - "custom_components.sws12500.translated_notification", translated_notification + "custom_components.sws12500.coordinator.translated_notification", translated_notification ) update_options = AsyncMock() - monkeypatch.setattr("custom_components.sws12500.update_options", update_options) + monkeypatch.setattr("custom_components.sws12500.coordinator.update_options", update_options) add_new_sensors = MagicMock() monkeypatch.setattr( - "custom_components.sws12500.sensor.add_new_sensors", add_new_sensors + "custom_components.sws12500.coordinator.add_new_sensors", add_new_sensors ) coordinator.async_set_updated_data = MagicMock() @@ -313,19 +326,19 @@ async def test_received_data_autodiscovery_human_readable_empty_branch_via_check coordinator = WeatherDataUpdateCoordinator(hass, entry) remapped = {"a": "1"} - monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: remapped) + monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped) monkeypatch.setattr( - "custom_components.sws12500.check_disabled", + "custom_components.sws12500.coordinator.check_disabled", lambda _remaped_items, _config: ["a"], ) - monkeypatch.setattr("custom_components.sws12500.loaded_sensors", lambda _c: []) + monkeypatch.setattr("custom_components.sws12500.coordinator.loaded_sensors", lambda _c: []) # Return a translation so the list comprehension would normally include an item. async def _translations(_hass, _domain, _key, **_kwargs): return "Name" - monkeypatch.setattr("custom_components.sws12500.translations", _translations) + monkeypatch.setattr("custom_components.sws12500.coordinator.translations", _translations) # Force checked(...) to return None when the code tries to validate translate_sensors as list[str]. def _checked_override(value, expected_type): @@ -333,19 +346,19 @@ async def test_received_data_autodiscovery_human_readable_empty_branch_via_check return None return value - monkeypatch.setattr("custom_components.sws12500.checked", _checked_override) + monkeypatch.setattr("custom_components.sws12500.coordinator.checked", _checked_override) translated_notification = AsyncMock() monkeypatch.setattr( - "custom_components.sws12500.translated_notification", translated_notification + "custom_components.sws12500.coordinator.translated_notification", translated_notification ) update_options = AsyncMock() - monkeypatch.setattr("custom_components.sws12500.update_options", update_options) + monkeypatch.setattr("custom_components.sws12500.coordinator.update_options", update_options) add_new_sensors = MagicMock() monkeypatch.setattr( - "custom_components.sws12500.sensor.add_new_sensors", add_new_sensors + "custom_components.sws12500.coordinator.add_new_sensors", add_new_sensors ) coordinator.async_set_updated_data = MagicMock() @@ -371,33 +384,33 @@ async def test_received_data_autodiscovery_extends_with_loaded_sensors_branch( coordinator = WeatherDataUpdateCoordinator(hass, entry) remapped = {"new": "1"} - monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: remapped) + monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped) # Autodiscovery finds one new sensor monkeypatch.setattr( - "custom_components.sws12500.check_disabled", + "custom_components.sws12500.coordinator.check_disabled", lambda _remaped_items, _config: ["new"], ) # Pretend there are already loaded sensors in options monkeypatch.setattr( - "custom_components.sws12500.loaded_sensors", lambda _c: ["existing"] + "custom_components.sws12500.coordinator.loaded_sensors", lambda _c: ["existing"] ) async def _translations(_hass, _domain, _key, **_kwargs): return "Name" - monkeypatch.setattr("custom_components.sws12500.translations", _translations) + monkeypatch.setattr("custom_components.sws12500.coordinator.translations", _translations) monkeypatch.setattr( - "custom_components.sws12500.translated_notification", AsyncMock() + "custom_components.sws12500.coordinator.translated_notification", AsyncMock() ) update_options = AsyncMock() - monkeypatch.setattr("custom_components.sws12500.update_options", update_options) + monkeypatch.setattr("custom_components.sws12500.coordinator.update_options", update_options) monkeypatch.setattr( - "custom_components.sws12500.sensor.add_new_sensors", MagicMock() + "custom_components.sws12500.coordinator.add_new_sensors", MagicMock() ) coordinator.async_set_updated_data = MagicMock() @@ -424,31 +437,31 @@ async def test_received_data_autodiscovery_translations_all_none_still_notifies_ coordinator = WeatherDataUpdateCoordinator(hass, entry) remapped = {"a": "1"} - monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: remapped) + monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: remapped) monkeypatch.setattr( - "custom_components.sws12500.check_disabled", + "custom_components.sws12500.coordinator.check_disabled", lambda _remaped_items, _config: ["a"], ) - monkeypatch.setattr("custom_components.sws12500.loaded_sensors", lambda _c: []) + monkeypatch.setattr("custom_components.sws12500.coordinator.loaded_sensors", lambda _c: []) # Force translations to return None for every lookup -> translate_sensors becomes None and human_readable "" async def _translations(_hass, _domain, _key, **_kwargs): return None - monkeypatch.setattr("custom_components.sws12500.translations", _translations) + monkeypatch.setattr("custom_components.sws12500.coordinator.translations", _translations) translated_notification = AsyncMock() monkeypatch.setattr( - "custom_components.sws12500.translated_notification", translated_notification + "custom_components.sws12500.coordinator.translated_notification", translated_notification ) update_options = AsyncMock() - monkeypatch.setattr("custom_components.sws12500.update_options", update_options) + monkeypatch.setattr("custom_components.sws12500.coordinator.update_options", update_options) add_new_sensors = MagicMock() monkeypatch.setattr( - "custom_components.sws12500.sensor.add_new_sensors", add_new_sensors + "custom_components.sws12500.coordinator.add_new_sensors", add_new_sensors ) coordinator.async_set_updated_data = MagicMock() @@ -469,17 +482,17 @@ async def test_received_data_dev_logging_calls_anonymize_and_logs(hass, monkeypa entry = _make_entry(wslink=False, api_id="id", api_key="key", dev_debug=True) coordinator = WeatherDataUpdateCoordinator(hass, entry) - monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: {"k": "v"}) + monkeypatch.setattr("custom_components.sws12500.coordinator.remap_items", lambda _d: {"k": "v"}) monkeypatch.setattr( - "custom_components.sws12500.check_disabled", + "custom_components.sws12500.coordinator.check_disabled", lambda _remaped_items, _config: [], ) anonymize = MagicMock(return_value={"safe": True}) - monkeypatch.setattr("custom_components.sws12500.anonymize", anonymize) + monkeypatch.setattr("custom_components.sws12500.coordinator.anonymize", anonymize) log_info = MagicMock() - monkeypatch.setattr("custom_components.sws12500._LOGGER.info", log_info) + monkeypatch.setattr("custom_components.sws12500.coordinator._LOGGER.info", log_info) coordinator.async_set_updated_data = MagicMock() diff --git a/tests/test_sensor_platform.py b/tests/test_sensor_platform.py index 8b865c6..a9f1201 100644 --- a/tests/test_sensor_platform.py +++ b/tests/test_sensor_platform.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock @@ -17,11 +17,7 @@ from custom_components.sws12500.const import ( WIND_SPEED, WSLINK, ) -from custom_components.sws12500.data import ( - ENTRY_ADD_ENTITIES, - ENTRY_COORDINATOR, - ENTRY_DESCRIPTIONS, -) +from custom_components.sws12500.data import SWSRuntimeData from custom_components.sws12500.sensor import ( WeatherSensor, _auto_enable_derived_sensors, @@ -32,25 +28,55 @@ from custom_components.sws12500.sensors_weather import SENSOR_TYPES_WEATHER_API from custom_components.sws12500.sensors_wslink import SENSOR_TYPES_WSLINK -@dataclass(slots=True) -class _ConfigEntryStub: - entry_id: str - options: dict[str, Any] +class _EcowittBridgeStub: + """Records the platform callback the sensor setup wires into the bridge.""" + + def __init__(self) -> None: + self.add_entities_cb: Any = None + + def set_add_entities(self, callback: Any) -> None: + self.add_entities_cb = callback class _CoordinatorStub: """Minimal coordinator stub for WeatherSensor and platform setup.""" - def __init__( - self, data: dict[str, Any] | None = None, *, config: Any | None = None - ) -> None: + def __init__(self, data: dict[str, Any] | None = None, *, options: dict[str, Any] | None = None) -> None: self.data = data if data is not None else {} - self.config = config + # WeatherSensor.__init__ reads coordinator.config.options for the dev-log flag. + self.config = SimpleNamespace(options=options if options is not None else {}) + self.ecowitt_bridge = _EcowittBridgeStub() + + +class _HealthCoordinatorStub: + """Stand-in for HealthCoordinator (health diagnostic sensors subscribe to it).""" + + def __init__(self) -> None: + self.data: dict[str, Any] = {} + + +def _make_entry( + *, options: dict[str, Any] | None = None, coordinator: _CoordinatorStub | None = None +) -> tuple[Any, _CoordinatorStub, SWSRuntimeData]: + """Build a config-entry stub carrying typed runtime_data, like the integration does.""" + coordinator = coordinator or _CoordinatorStub() + runtime = SWSRuntimeData( + coordinator=coordinator, # type: ignore[arg-type] + health_coordinator=_HealthCoordinatorStub(), # type: ignore[arg-type] + last_options={}, + ) + entry = SimpleNamespace( + entry_id="test_entry_id", + options=options if options is not None else {}, + runtime_data=runtime, + ) + return entry, coordinator, runtime @pytest.fixture def hass(): - # Use a very small hass-like object; sensor platform uses only `hass.data`. + # Sensor platform setup only forwards hass to health_sensor.async_setup_entry, + # which ignores it, and add_new_sensors deletes it. A tiny stub is enough. class _Hass: def __init__(self) -> None: self.data: dict[str, Any] = {} @@ -58,11 +84,6 @@ def hass(): return _Hass() -@pytest.fixture -def config_entry() -> _ConfigEntryStub: - return _ConfigEntryStub(entry_id="test_entry_id", options={}) - - def _capture_add_entities(): captured: list[Any] = [] @@ -72,207 +93,118 @@ def _capture_add_entities(): return captured, _add_entities +def _weather_keys(captured: list[Any]) -> set[str]: + return {e.entity_description.key for e in captured if isinstance(e, WeatherSensor)} + + +# --- _auto_enable_derived_sensors ------------------------------------------ + + def test_auto_enable_derived_sensors_wind_azimut(): - requested = {WIND_DIR} - expanded = _auto_enable_derived_sensors(requested) + expanded = _auto_enable_derived_sensors({WIND_DIR}) assert WIND_DIR in expanded assert WIND_AZIMUT in expanded def test_auto_enable_derived_sensors_heat_index(): - requested = {OUTSIDE_TEMP, OUTSIDE_HUMIDITY} - expanded = _auto_enable_derived_sensors(requested) + expanded = _auto_enable_derived_sensors({OUTSIDE_TEMP, OUTSIDE_HUMIDITY}) assert HEAT_INDEX in expanded def test_auto_enable_derived_sensors_chill_index(): - requested = {OUTSIDE_TEMP, WIND_SPEED} - expanded = _auto_enable_derived_sensors(requested) + expanded = _auto_enable_derived_sensors({OUTSIDE_TEMP, WIND_SPEED}) assert CHILL_INDEX in expanded -@pytest.mark.asyncio -async def test_sensor_async_setup_entry_no_coordinator_is_noop(hass, config_entry): - # No entry dict created by integration yet; async_setup_entry should be defensive and no-op. - captured, add_entities = _capture_add_entities() - - await async_setup_entry(hass, config_entry, add_entities) - - assert captured == [] +# --- async_setup_entry ----------------------------------------------------- @pytest.mark.asyncio -async def test_sensor_async_setup_entry_stores_callback_and_descriptions_even_if_no_sensors_to_load( - hass, config_entry -): - # Prepare runtime entry data and coordinator like integration does. - hass.data.setdefault("sws12500", {}) - hass.data["sws12500"][config_entry.entry_id] = { - ENTRY_COORDINATOR: _CoordinatorStub() - } - +async def test_setup_stores_callback_and_descriptions_even_without_sensors_to_load(hass): + entry, coordinator, runtime = _make_entry() captured, add_entities = _capture_add_entities() - # No SENSORS_TO_LOAD set -> early return, but it should still store callback + descriptions. - await async_setup_entry(hass, config_entry, add_entities) + await async_setup_entry(hass, entry, add_entities) - entry_data = hass.data["sws12500"][config_entry.entry_id] - assert entry_data[ENTRY_ADD_ENTITIES] is add_entities - assert isinstance(entry_data[ENTRY_DESCRIPTIONS], dict) - assert captured == [] + # Callback + description map persisted for dynamic entity creation. + assert runtime.add_sensor_entities is add_entities + assert isinstance(runtime.sensor_descriptions, dict) + # Ecowitt bridge wired up even though there are no sensors to load yet. + assert coordinator.ecowitt_bridge.add_entities_cb is add_entities + # No weather sensors created (only health diagnostics, which we ignore here). + assert _weather_keys(captured) == set() @pytest.mark.asyncio -async def test_sensor_async_setup_entry_selects_weather_api_descriptions_when_wslink_disabled( - hass, config_entry -): - hass.data.setdefault("sws12500", {}) - hass.data["sws12500"][config_entry.entry_id] = { - ENTRY_COORDINATOR: _CoordinatorStub() - } +async def test_setup_selects_weather_api_descriptions_when_wslink_disabled(hass): + entry, _coordinator, runtime = _make_entry(options={WSLINK: False}) + _captured, add_entities = _capture_add_entities() - captured, add_entities = _capture_add_entities() + await async_setup_entry(hass, entry, add_entities) - # Explicitly disabled WSLINK - config_entry.options[WSLINK] = False - - await async_setup_entry(hass, config_entry, add_entities) - - descriptions = hass.data["sws12500"][config_entry.entry_id][ENTRY_DESCRIPTIONS] - assert set(descriptions.keys()) == {d.key for d in SENSOR_TYPES_WEATHER_API} - assert captured == [] + assert set(runtime.sensor_descriptions.keys()) == {d.key for d in SENSOR_TYPES_WEATHER_API} @pytest.mark.asyncio -async def test_sensor_async_setup_entry_selects_wslink_descriptions_when_wslink_enabled( - hass, config_entry -): - hass.data.setdefault("sws12500", {}) - hass.data["sws12500"][config_entry.entry_id] = { - ENTRY_COORDINATOR: _CoordinatorStub() - } +async def test_setup_selects_wslink_descriptions_when_wslink_enabled(hass): + entry, _coordinator, runtime = _make_entry(options={WSLINK: True}) + _captured, add_entities = _capture_add_entities() - captured, add_entities = _capture_add_entities() + await async_setup_entry(hass, entry, add_entities) - config_entry.options[WSLINK] = True - - await async_setup_entry(hass, config_entry, add_entities) - - descriptions = hass.data["sws12500"][config_entry.entry_id][ENTRY_DESCRIPTIONS] - assert set(descriptions.keys()) == {d.key for d in SENSOR_TYPES_WSLINK} - assert captured == [] + assert set(runtime.sensor_descriptions.keys()) == {d.key for d in SENSOR_TYPES_WSLINK} @pytest.mark.asyncio -async def test_sensor_async_setup_entry_adds_requested_entities_and_auto_enables_derived( - hass, config_entry -): - hass.data.setdefault("sws12500", {}) - coordinator = _CoordinatorStub() - hass.data["sws12500"][config_entry.entry_id] = {ENTRY_COORDINATOR: coordinator} - - captured, add_entities = _capture_add_entities() - - # Request WIND_DIR, OUTSIDE_TEMP, OUTSIDE_HUMIDITY, WIND_SPEED -> should auto-add derived keys too. - config_entry.options[WSLINK] = False - config_entry.options[SENSORS_TO_LOAD] = [ - WIND_DIR, - OUTSIDE_TEMP, - OUTSIDE_HUMIDITY, - WIND_SPEED, - ] - - await async_setup_entry(hass, config_entry, add_entities) - - # We should have at least those requested + derived in the added entities. - keys_added = { - e.entity_description.key for e in captured if isinstance(e, WeatherSensor) - } - assert WIND_DIR in keys_added - assert OUTSIDE_TEMP in keys_added - assert OUTSIDE_HUMIDITY in keys_added - assert WIND_SPEED in keys_added - - # Derived: - assert WIND_AZIMUT in keys_added - assert HEAT_INDEX in keys_added - assert CHILL_INDEX in keys_added - - -def test_add_new_sensors_is_noop_when_domain_missing(hass, config_entry): - called = False - - def add_entities(_entities: list[Any]) -> None: - nonlocal called - called = True - - # No hass.data["sws12500"] at all. - add_new_sensors(hass, config_entry, keys=["anything"]) - - assert called is False - - -def test_add_new_sensors_is_noop_when_entry_missing(hass, config_entry): - hass.data["sws12500"] = {} - called = False - - def add_entities(_entities: list[Any]) -> None: - nonlocal called - called = True - - add_new_sensors(hass, config_entry, keys=["anything"]) - - assert called is False - - -def test_add_new_sensors_is_noop_when_callback_or_descriptions_missing( - hass, config_entry -): - hass.data["sws12500"] = { - config_entry.entry_id: {ENTRY_COORDINATOR: _CoordinatorStub()} - } - called = False - - def add_entities(_entities: list[Any]) -> None: - nonlocal called - called = True - - # Missing ENTRY_ADD_ENTITIES + ENTRY_DESCRIPTIONS -> no-op. - add_new_sensors(hass, config_entry, keys=["anything"]) - - assert called is False - - -def test_add_new_sensors_ignores_unknown_keys(hass, config_entry): - hass.data["sws12500"] = { - config_entry.entry_id: { - ENTRY_COORDINATOR: _CoordinatorStub(), - ENTRY_ADD_ENTITIES: MagicMock(), - ENTRY_DESCRIPTIONS: {}, # nothing known +async def test_setup_adds_requested_entities_and_auto_enables_derived(hass): + entry, _coordinator, _runtime = _make_entry( + options={ + WSLINK: False, + SENSORS_TO_LOAD: [WIND_DIR, OUTSIDE_TEMP, OUTSIDE_HUMIDITY, WIND_SPEED], } - } + ) + captured, add_entities = _capture_add_entities() - add_new_sensors(hass, config_entry, keys=["unknown_key"]) + await async_setup_entry(hass, entry, add_entities) - hass.data["sws12500"][config_entry.entry_id][ENTRY_ADD_ENTITIES].assert_not_called() + keys_added = _weather_keys(captured) + # Requested. + assert {WIND_DIR, OUTSIDE_TEMP, OUTSIDE_HUMIDITY, WIND_SPEED} <= keys_added + # Derived. + assert {WIND_AZIMUT, HEAT_INDEX, CHILL_INDEX} <= keys_added -def test_add_new_sensors_adds_known_keys(hass, config_entry): - coordinator = _CoordinatorStub() +# --- add_new_sensors ------------------------------------------------------- + + +def test_add_new_sensors_is_noop_when_callback_missing(hass): + entry, _coordinator, runtime = _make_entry() + # Platform not set up yet -> no stored callback. + assert runtime.add_sensor_entities is None + + # Must not raise. + add_new_sensors(hass, entry, keys=["anything"]) + + +def test_add_new_sensors_ignores_unknown_keys(hass): + entry, _coordinator, runtime = _make_entry() add_entities = MagicMock() + runtime.add_sensor_entities = add_entities + runtime.sensor_descriptions = {} # nothing known - # Use one known description from the weather API list. + add_new_sensors(hass, entry, keys=["unknown_key"]) + + add_entities.assert_not_called() + + +def test_add_new_sensors_adds_known_keys(hass): + entry, _coordinator, runtime = _make_entry() + add_entities = MagicMock() known_desc = SENSOR_TYPES_WEATHER_API[0] + runtime.add_sensor_entities = add_entities + runtime.sensor_descriptions = {known_desc.key: known_desc} - hass.data["sws12500"] = { - config_entry.entry_id: { - ENTRY_COORDINATOR: coordinator, - ENTRY_ADD_ENTITIES: add_entities, - ENTRY_DESCRIPTIONS: {known_desc.key: known_desc}, - } - } - - add_new_sensors(hass, config_entry, keys=[known_desc.key]) + add_new_sensors(hass, entry, keys=[known_desc.key]) add_entities.assert_called_once() (entities_arg,) = add_entities.call_args.args diff --git a/tests/test_t9_air_quality.py b/tests/test_t9_air_quality.py index c6d3c2f..4c1f380 100644 --- a/tests/test_t9_air_quality.py +++ b/tests/test_t9_air_quality.py @@ -70,11 +70,13 @@ def test_t9_keys_are_remapped() -> None: def test_connection_gated_sensors_definition() -> None: - assert CONNECTION_GATED_SENSORS == {"t9cn": [HCHO, VOC, T9_BATTERY]} + # The T9 HCHO/VOC probe is gated by its own connection flag. (Multi-channel + # CH2-CH8 probes have their own gates too; we only assert the T9 one here.) + assert CONNECTION_GATED_SENSORS["t9cn"] == [HCHO, VOC, T9_BATTERY] def test_t9_battery_is_non_binary_only() -> None: - assert BATTERY_NON_BINARY == [T9_BATTERY] + assert BATTERY_NON_BINARY == (T9_BATTERY,) # the 0-5 / percentage battery must not be treated as a binary low/normal one assert T9_BATTERY not in BATTERY_LIST @@ -84,7 +86,7 @@ def test_voc_level_map_is_complete_and_ordered() -> None: assert set(VOC_LEVEL_MAP) == {1, 2, 3, 4, 5} assert set(VOC_LEVEL_MAP.values()) == set(VOCLevel) assert VOC_LEVEL_MAP[1] is VOCLevel.UNHEALTHY - assert VOC_LEVEL_MAP[5] is VOCLevel.EXCELENT + assert VOC_LEVEL_MAP[5] is VOCLevel.EXCELLENT assert [member.value for member in VOCLevel] == [ "unhealthy", "poor", @@ -109,7 +111,7 @@ def test_voc_level_to_text_handles_empty(empty) -> None: ("2", VOCLevel.POOR), ("3", VOCLevel.MODERATE), ("4", VOCLevel.GOOD), - ("5", VOCLevel.EXCELENT), + ("5", VOCLevel.EXCELLENT), (3, VOCLevel.MODERATE), ], ) @@ -184,8 +186,8 @@ def test_hcho_entity_description(wslink_descriptions) -> None: assert description.device_class is SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS assert description.native_unit_of_measurement == CONCENTRATION_PARTS_PER_BILLION assert description.state_class is SensorStateClass.MEASUREMENT - # value_fn is a pass-through (typing.cast is a no-op at runtime; HA coerces the str) - assert description.value_fn("57") == "57" + # HCHO is a numeric ppb concentration, so value_fn coerces to int. + assert description.value_fn("57") == 57 def test_voc_entity_description(wslink_descriptions) -> None: diff --git a/tests/test_weather_sensor_entity.py b/tests/test_weather_sensor_entity.py index 2c5f10a..45f55eb 100644 --- a/tests/test_weather_sensor_entity.py +++ b/tests/test_weather_sensor_entity.py @@ -5,8 +5,6 @@ from types import SimpleNamespace from typing import Any, Callable from unittest.mock import MagicMock -import pytest - from custom_components.sws12500.const import DOMAIN from custom_components.sws12500.sensor import WeatherSensor @@ -33,7 +31,9 @@ class _CoordinatorStub: self, data: dict[str, Any] | None = None, *, config: Any | None = None ): self.data = data if data is not None else {} - self.config = config + # WeatherSensor.__init__ reads coordinator.config.options for the dev-log flag, + # so default to a config with empty options when the test doesn't supply one. + self.config = config if config is not None else SimpleNamespace(options={}) def test_native_value_prefers_value_from_data_fn_success():