"""Tests for issue #327: remembering a mode the device reports itself in but never advertises in supportedModes. The reporters' unit (ARTIK051_PRAC_20K) isn't in the corpus, so the scenario runs on an existing fixture whose /mode/convenient/vs/0 genuinely lacks Quiet -- airconditioner_tp1x_fac_time_23k, supportedModes [Off, Sleep, Nano, NanoSleep] -- by applying the rep their firmware sends: a current mode of 'Quiet' with that same supportedModes list unchanged. """ from __future__ import annotations import asyncio from typing import Any, cast import pytest from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.localthings.climate import LocalThingsClimate from custom_components.localthings.const import ( CONF_LEARN_MODES, CONF_LEARNED_MODES, DOMAIN, ) from custom_components.localthings.coordinator import LocalThingsCoordinator from custom_components.localthings.learned import ( LEARNABLE, MODES_FIELD, SUPPORTED_FIELD, LearnedModes, ) from custom_components.localthings.registry.entities import ClimateDesc from tests.test_subdevice_discovery import ENTRY_DATA, _climate_bound, _discover FIXTURE = "airconditioner_tp1x_fac_time_23k" CONVENIENT = "/mode/convenient/vs/0" ADVERTISED = ["Off", "Sleep", "Nano", "NanoSleep"] # What the reporters' firmware sends while the unit is in Quiet: the mode # named as current, and a supported list that still omits it. `modes` is a # bare string here, matching the dump in the issue. QUIET_REP = {MODES_FIELD: "Quiet", SUPPORTED_FIELD: ADVERTISED} # --------------------------------------------------------------------------- # The store itself # --------------------------------------------------------------------------- def test_learns_a_current_mode_missing_from_the_supported_list(): learned = LearnedModes() assert learned.observe(CONVENIENT, QUIET_REP) == ["Quiet"] assert learned.codes(CONVENIENT) == ["Quiet"] def test_relearning_the_same_mode_is_not_a_change(): """The device reports the same rep on every poll while it sits in the mode, so only the first one may report back as something to persist.""" learned = LearnedModes() assert learned.observe(CONVENIENT, QUIET_REP) == ["Quiet"] assert learned.observe(CONVENIENT, QUIET_REP) == [] assert learned.codes(CONVENIENT) == ["Quiet"] def test_an_advertised_mode_is_never_learned(): learned = LearnedModes() rep = {MODES_FIELD: "Sleep", SUPPORTED_FIELD: ADVERTISED} assert learned.observe(CONVENIENT, rep) == [] assert learned.codes(CONVENIENT) == [] def test_a_rep_with_no_supported_list_teaches_nothing(): """ "Missing from the list" is only meaningful against a list that exists -- a board publishing none would otherwise get an option list invented out of whatever it happened to be doing.""" learned = LearnedModes() assert learned.observe(CONVENIENT, {MODES_FIELD: "Quiet"}) == [] assert learned.codes(CONVENIENT) == [] def test_the_allowlist_is_only_the_convenient_href(): """The guard that keeps this feature off resources whose current value isn't a selectable option -- an oven idling in 'NoOperation' on /mode/vs/0, whose codes would become permanent junk options in that unit's cook-mode select.""" assert set(LEARNABLE) == {CONVENIENT} def test_two_subdevices_learn_separately(): """Keyed by the actual on-the-wire href (issue #177), so a composite appliance's second indoor unit doesn't inherit the first's gap.""" learned = LearnedModes() learned.observe("/mode/convenient/vs/1", QUIET_REP) assert learned.codes("/mode/convenient/vs/1") == ["Quiet"] assert learned.codes(CONVENIENT) == [] @pytest.mark.parametrize( "stored", [ None, "not-a-dict", {"/mode/convenient/vs/0": None}, {"/mode/convenient/vs/0": [""]}, {"/mode/convenient/vs/0": [1, 2]}, {"/mode/convenient/vs/0": {"x.com.samsung.da.supportedModes": ["Quiet"]}}, ], ) def test_malformed_stored_data_restores_as_empty(stored): """It round-trips through the config entry as plain JSON, so a hand-edited .storage file must not be able to break setup.""" assert LearnedModes(stored).snapshot() == {} def test_well_formed_stored_data_restores(): learned = LearnedModes({CONVENIENT: ["Quiet"]}) assert learned.codes(CONVENIENT) == ["Quiet"] def test_clear_forgets_everything(): learned = LearnedModes({CONVENIENT: ["Quiet"]}) learned.clear() assert learned.snapshot() == {} def test_every_learnable_href_is_one_climate_resolves(): """climate._supported is the only consumer that unions learned codes in today, so an href added to LEARNABLE that climate never reads would be learned, persisted, and never offered anywhere. The coordinator enforces the device half of this (only hrefs a climate entity binds are learnable); this is the static half.""" from custom_components.localthings.climate import ( CONVENIENT_HREF, MODE_HREF, WIND_DIRECTION_HREF, WIND_STRENGTH_HREF, ) assert set(LEARNABLE) <= {MODE_HREF, WIND_STRENGTH_HREF, WIND_DIRECTION_HREF, CONVENIENT_HREF} # --------------------------------------------------------------------------- # End to end: device reports it -> HA offers it -> the entry remembers it # --------------------------------------------------------------------------- def _entry(hass: HomeAssistant, data: dict | None = None, options: dict | None = None): entry = MockConfigEntry( domain=DOMAIN, data={**ENTRY_DATA, **(data or {})}, options=options or {}, unique_id="localthings_LEARNED-TEST", ) entry.add_to_hass(hass) return entry async def _flush(hass: HomeAssistant) -> None: """Let a persist scheduled from the applying thread land. The coordinator marshals it with hass.add_job (the same way it marshals its state push), which goes through call_soon_threadsafe -- async_block_till_done alone doesn't pick that up in this harness. """ await asyncio.sleep(0) await hass.async_block_till_done() async def _climate(hass: HomeAssistant, entry) -> tuple[LocalThingsCoordinator, Any]: coordinator = LocalThingsCoordinator(hass, entry) await _discover(coordinator, FIXTURE) return coordinator, LocalThingsClimate(coordinator, _climate_bound(coordinator, None)) async def test_the_fixture_really_does_not_advertise_quiet(hass: HomeAssistant): """Guard for every assertion below: they'd all pass vacuously against a unit that advertised Quiet in the first place.""" _, entity = await _climate(hass, _entry(hass)) assert "quiet" not in entity.preset_modes async def test_a_reported_mode_becomes_a_preset(hass: HomeAssistant): coordinator, entity = await _climate(hass, _entry(hass)) coordinator._observe.apply(CONVENIENT, QUIET_REP, source="poll") assert entity.preset_mode == "quiet" assert "quiet" in entity.preset_modes # Alongside, not instead of, what the device does advertise. assert "sleep" in entity.preset_modes async def test_a_learned_preset_is_writable(hass: HomeAssistant, monkeypatch): """The reason the union belongs in _supported: async_set_preset_mode reverse-resolves the device code from the same list, so read and write are fixed by one change.""" coordinator, entity = await _climate(hass, _entry(hass)) coordinator._observe.apply(CONVENIENT, QUIET_REP, source="poll") sent: list = [] async def _record(bound, payload): sent.append(payload) monkeypatch.setattr(coordinator, "async_send_command", _record) await entity.async_set_preset_mode("quiet") assert sent == [("preset", "Quiet")] async def test_learning_persists_onto_the_config_entry(hass: HomeAssistant): entry = _entry(hass) coordinator, _ = await _climate(hass, entry) coordinator._observe.apply(CONVENIENT, QUIET_REP, source="poll") await _flush(hass) assert entry.data[CONF_LEARNED_MODES] == {CONVENIENT: ["Quiet"]} async def test_a_learned_preset_survives_a_restart(hass: HomeAssistant): """The point of persisting: the unit only names Quiet while it is in Quiet, so a restart in any other mode would otherwise lose it until someone reached for the physical remote again.""" entry = _entry(hass, data={CONF_LEARNED_MODES: {CONVENIENT: ["Quiet"]}}) _, entity = await _climate(hass, entry) # Nothing applied this run -- the fixture's own rep says Off, and its # supportedModes still omits Quiet. assert entity.preset_mode == "none" assert "quiet" in entity.preset_modes async def test_a_device_with_no_climate_entity_learns_nothing(hass: HomeAssistant): """The href alone isn't a sufficient key: the dehumidifier registry declares this same /mode/convenient/vs/0 explicitly unmodeled (no live current-value field), so a code learned there would be persisted for a resource nothing will ever offer.""" entry = _entry(hass) coordinator = LocalThingsCoordinator(hass, entry) await _discover(coordinator, "dehumidifier") assert not any(isinstance(b.desc, ClimateDesc) for b in coordinator.bound) coordinator._observe.apply( CONVENIENT, {MODES_FIELD: "Quiet", SUPPORTED_FIELD: ADVERTISED}, source="poll" ) assert coordinator.learned_snapshot() == {} async def test_an_optimistic_write_teaches_nothing(hass: HomeAssistant): """An optimistic cache entry is the value this integration just wrote, not something the device reported.""" coordinator, _ = await _climate(hass, _entry(hass)) coordinator._observe.apply(CONVENIENT, QUIET_REP, source="optimistic") assert coordinator.learned_snapshot() == {} async def test_a_partial_notify_still_learns(hass: HomeAssistant): """An OBSERVE notify can carry `modes` alone (issue #27). Learning sees the merged rep, so the supported list from the last full poll is still there to judge it against.""" coordinator, entity = await _climate(hass, _entry(hass)) coordinator._observe.apply(CONVENIENT, {MODES_FIELD: "Quiet"}, source="observe") assert "quiet" in entity.preset_modes async def test_the_option_turns_off_both_halves(hass: HomeAssistant): """Off means the stock list, immediately -- nothing new is learned and nothing already learned is offered.""" entry = _entry( hass, data={CONF_LEARNED_MODES: {CONVENIENT: ["Smart"]}}, options={CONF_LEARN_MODES: False}, ) coordinator, entity = await _climate(hass, entry) coordinator._observe.apply(CONVENIENT, QUIET_REP, source="poll") await _flush(hass) assert "smart" not in entity.preset_modes assert "quiet" not in entity.preset_modes # Kept, not discarded -- turning the option back on restores it, and # nothing new was written while it was off. assert coordinator.learned_snapshot() == {CONVENIENT: ["Smart"]} assert entry.data[CONF_LEARNED_MODES] == {CONVENIENT: ["Smart"]} async def test_forgetting_clears_the_store_and_the_entry(hass: HomeAssistant): entry = _entry(hass, data={CONF_LEARNED_MODES: {CONVENIENT: ["Quiet"]}}) coordinator, entity = await _climate(hass, entry) coordinator.forget_learned_modes() await _flush(hass) assert "quiet" not in entity.preset_modes assert entry.data[CONF_LEARNED_MODES] == {} async def test_forgetting_clears_a_record_the_store_rejected(hass: HomeAssistant): """A malformed persisted record is dropped on restore, so the store is empty while the entry still holds it. Forget has to reach it anyway -- it's the only control that can.""" entry = _entry(hass, data={CONF_LEARNED_MODES: {CONVENIENT: None}}) coordinator, _ = await _climate(hass, entry) assert coordinator.learned_snapshot() == {} coordinator.forget_learned_modes() await _flush(hass) assert entry.data[CONF_LEARNED_MODES] == {} async def test_diagnostics_report_what_was_learned( hass: HomeAssistant, enable_custom_integrations, ): """Kept out of the `resources` dump on purpose -- that stays exactly what the device said, so a triager can still see the gap.""" from custom_components.localthings.diagnostics import ( async_get_config_entry_diagnostics, ) entry = _entry(hass) coordinator, _ = await _climate(hass, entry) coordinator._observe.apply(CONVENIENT, QUIET_REP, source="poll") hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diag = await async_get_config_entry_diagnostics(hass, cast(Any, entry)) assert diag["learned_modes"] == {"enabled": True, "codes": {CONVENIENT: ["Quiet"]}} assert diag["resources"][CONVENIENT][SUPPORTED_FIELD] == ADVERTISED