Address review findings on the learned-modes store
Flatten the store to {href: [codes]}. One href carries one LEARNABLE
rule, so keying the codes by the rule's supported field too let the
write side (rule.supported_field) and both read sides (the module-level
SUPPORTED_FIELD) disagree the moment a rule used a different field --
codes learned and persisted, then never offered.
The options flow's reset step read the persisted value raw in the
entry-not-loaded branch, so malformed data aborted the one screen that
can clear it; route it through LearnedModes like every other reader.
For the same reason forget_learned_modes() now persists whenever the
entry carries a record, not only when the in-memory store had one: a
record _coerce rejected at startup exists only on the entry.
This commit is contained in:
9 files changed
+78
-60
No files matched your search
@@ -1013,12 +1013,22 @@ async def test_learned_modes_option_can_be_turned_off(hass: HomeAssistant) -> No
|
||||
assert entry.options[CONF_LEARN_MODES] is False
|
||||
|
||||
|
||||
async def test_forget_learned_modes_clears_the_entry(hass: HomeAssistant) -> None:
|
||||
@pytest.mark.parametrize(
|
||||
("stored", "listed"),
|
||||
[
|
||||
({"/mode/convenient/vs/0": ["Quiet"]}, "Quiet"),
|
||||
# Malformed -- nothing writes this shape, but a hand-edited
|
||||
# .storage can hold it, and this step is the one screen that can
|
||||
# clear it, so it must not be the one screen that trips over it.
|
||||
({"/mode/convenient/vs/0": None}, "(none)"),
|
||||
],
|
||||
)
|
||||
async def test_forget_learned_modes_clears_the_entry(hass: HomeAssistant, stored, listed) -> None:
|
||||
"""The reset step works on an unloaded entry too, by dropping the
|
||||
persisted copy directly -- that's all a reload would restore from."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={**ENTRY_DATA, CONF_LEARNED_MODES: {"/mode/convenient/vs/0": {"f": ["Quiet"]}}},
|
||||
data={**ENTRY_DATA, CONF_LEARNED_MODES: stored},
|
||||
unique_id=f"localthings_{MOCK_SERIAL}",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
@@ -1028,7 +1038,7 @@ async def test_forget_learned_modes_clears_the_entry(hass: HomeAssistant) -> Non
|
||||
result["flow_id"], user_input={"next_step_id": "forget_learned_modes"}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["description_placeholders"] == {"codes": "Quiet"}
|
||||
assert result["description_placeholders"] == {"codes": listed}
|
||||
|
||||
result = await hass.config_entries.options.async_configure(result["flow_id"], user_input={})
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ class _FakeCoordinator:
|
||||
async def async_send_command(self, bound, payload):
|
||||
self.commands.append((bound, payload))
|
||||
|
||||
def learned_modes(self, href, field=None):
|
||||
def learned_modes(self, href):
|
||||
# Nothing learned in this stub -- issue #327's store lives on the
|
||||
# real coordinator; climate._supported unions it in.
|
||||
return []
|
||||
|
||||
@@ -48,7 +48,7 @@ class _FakeCoordinator:
|
||||
async def async_send_command(self, bound, payload):
|
||||
self.commands.append((bound, payload))
|
||||
|
||||
def learned_modes(self, href, field=None):
|
||||
def learned_modes(self, href):
|
||||
# Nothing learned in this stub -- issue #327's store lives on the
|
||||
# real coordinator; climate._supported unions it in.
|
||||
return []
|
||||
|
||||
@@ -119,7 +119,7 @@ def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
|
||||
# canonical view is just the raw snapshot (issue #177).
|
||||
return self.last_resources
|
||||
|
||||
def learned_modes(self, href, field=None):
|
||||
def learned_modes(self, href):
|
||||
# Nothing learned in this stub -- issue #327's store lives on
|
||||
# the real coordinator; climate._supported unions it in.
|
||||
return []
|
||||
|
||||
+29
-18
@@ -104,9 +104,10 @@ def test_two_subdevices_learn_separately():
|
||||
[
|
||||
None,
|
||||
"not-a-dict",
|
||||
{"/mode/convenient/vs/0": "not-a-dict"},
|
||||
{"/mode/convenient/vs/0": {SUPPORTED_FIELD: [""]}},
|
||||
{"/mode/convenient/vs/0": {SUPPORTED_FIELD: [1, 2]}},
|
||||
{"/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):
|
||||
@@ -116,15 +117,14 @@ def test_malformed_stored_data_restores_as_empty(stored):
|
||||
|
||||
|
||||
def test_well_formed_stored_data_restores():
|
||||
learned = LearnedModes({CONVENIENT: {SUPPORTED_FIELD: ["Quiet"]}})
|
||||
learned = LearnedModes({CONVENIENT: ["Quiet"]})
|
||||
assert learned.codes(CONVENIENT) == ["Quiet"]
|
||||
|
||||
|
||||
def test_clear_reports_whether_there_was_anything_to_forget():
|
||||
learned = LearnedModes({CONVENIENT: {SUPPORTED_FIELD: ["Quiet"]}})
|
||||
assert learned.clear() is True
|
||||
def test_clear_forgets_everything():
|
||||
learned = LearnedModes({CONVENIENT: ["Quiet"]})
|
||||
learned.clear()
|
||||
assert learned.snapshot() == {}
|
||||
assert learned.clear() is False
|
||||
|
||||
|
||||
def test_every_learnable_href_is_one_climate_resolves():
|
||||
@@ -215,14 +215,14 @@ async def test_learning_persists_onto_the_config_entry(hass: HomeAssistant):
|
||||
coordinator._observe.apply(CONVENIENT, QUIET_REP, source="poll")
|
||||
await _flush(hass)
|
||||
|
||||
assert entry.data[CONF_LEARNED_MODES] == {CONVENIENT: {SUPPORTED_FIELD: ["Quiet"]}}
|
||||
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: {SUPPORTED_FIELD: ["Quiet"]}}})
|
||||
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
|
||||
@@ -255,7 +255,7 @@ async def test_the_option_turns_off_both_halves(hass: HomeAssistant):
|
||||
nothing already learned is offered."""
|
||||
entry = _entry(
|
||||
hass,
|
||||
data={CONF_LEARNED_MODES: {CONVENIENT: {SUPPORTED_FIELD: ["Smart"]}}},
|
||||
data={CONF_LEARNED_MODES: {CONVENIENT: ["Smart"]}},
|
||||
options={CONF_LEARN_MODES: False},
|
||||
)
|
||||
coordinator, entity = await _climate(hass, entry)
|
||||
@@ -266,12 +266,12 @@ async def test_the_option_turns_off_both_halves(hass: HomeAssistant):
|
||||
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: {SUPPORTED_FIELD: ["Smart"]}}
|
||||
assert entry.data[CONF_LEARNED_MODES] == {CONVENIENT: {SUPPORTED_FIELD: ["Smart"]}}
|
||||
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: {SUPPORTED_FIELD: ["Quiet"]}}})
|
||||
entry = _entry(hass, data={CONF_LEARNED_MODES: {CONVENIENT: ["Quiet"]}})
|
||||
coordinator, entity = await _climate(hass, entry)
|
||||
|
||||
coordinator.forget_learned_modes()
|
||||
@@ -281,6 +281,20 @@ async def test_forgetting_clears_the_store_and_the_entry(hass: HomeAssistant):
|
||||
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,
|
||||
@@ -298,8 +312,5 @@ async def test_diagnostics_report_what_was_learned(
|
||||
|
||||
diag = await async_get_config_entry_diagnostics(hass, cast(Any, entry))
|
||||
|
||||
assert diag["learned_modes"] == {
|
||||
"enabled": True,
|
||||
"codes": {CONVENIENT: {SUPPORTED_FIELD: ["Quiet"]}},
|
||||
}
|
||||
assert diag["learned_modes"] == {"enabled": True, "codes": {CONVENIENT: ["Quiet"]}}
|
||||
assert diag["resources"][CONVENIENT][SUPPORTED_FIELD] == ADVERTISED
|
||||
Reference in new issue
Block a user