Harden the cloud-courses toggle against three review findings

- _on_cloud_courses_changed cleared the canonical-view cache but never
  pushed state: select.py's current_option reads coordinator.data,
  which only moves on async_set_updated_data, so a change here (the
  new toggle, or apply_cloud_courses naming a program -- which has
  called this same method since before the toggle existed) sat stale
  in the UI until an unrelated poll or observe happened to run next.
  Now calls _push_cache_snapshot() too.

- _refresh_cloud_course_issue now runs from __init__.py's
  options-update listener on every entry save, not just a
  cloud-course-specific one. Saving an unrelated option
  (CONF_BYPASS_REMOTE_CONTROL, say) before this device's first poll,
  or while it's rehydrated offline, read /course/vs/0 as empty --
  indistinguishable from "nothing pending" -- and would delete a
  Repair a real poll had every reason to raise. Now a no-op on an
  empty rep, leaving whatever issue state already exists untouched
  until a real poll can judge it.

- The "cloud_courses" menu's off-state note was a raw English string
  built in config_flow.py and substituted via description_placeholders
  into all 7 locales' descriptions -- unlike the SmartThings screen
  names quoted elsewhere (deliberately English everywhere; that's a
  third-party app's own label, not ours), this one named LocalThings'
  own "Offer downloaded cycles"/"Device settings" labels, which are
  translated per locale and should have matched. Replaced with a
  permanent, state-independent sentence translated in the catalog
  itself, in all 7 locales, instead of conditional Python-built text.

Also restores a word an earlier edit dropped from
async_step_cloud_courses's docstring ("can complete confidently").

Tests: two new regression tests, each confirmed to fail against the
pre-fix code before being fixed -- one drives coordinator.data through
a toggle via a fixture already sitting on a one-time cloud override, so
current_option actually depends on the cloud store instead of falling
back to the raw course code; the other simulates a second coordinator
against the same entry with an empty resource cache (a not-yet-polled
restart) and confirms an existing Repair survives an unrelated option
save. Full suite (1585 tests), ruff, and `ty check custom_components
tests` all pass.
This commit is contained in:
Marc Billow committed 2026-08-16 05:25:36 +00:00
1 parent d5dc6421ba
commit c0ef45c7c8
10 files changed
+89 -22

No files matched your search

+45
View File
@@ -332,6 +332,51 @@ async def test_disabling_does_not_stop_passive_observation(hass: HomeAssistant):
assert entry.data[CONF_CLOUD_COURSES]["slots"]["55"]["blob"] == SPORTS
async def test_disabling_pushes_the_new_current_option_immediately(hass: HomeAssistant):
"""select.py's current_option reads coordinator.data, not
canonical_resources() -- clearing only the canonical-view cache left
it stale until whatever poll happened to run next. The fixture is
sitting on a one-time Jeans ('6B') override, so naming it is what
makes the live selection actually depend on the cloud store rather
than falling back to the raw course code."""
entry = _entry(hass)
coordinator = await _coordinator(hass, entry)
coordinator.apply_cloud_courses({"55": "Sports", "6B": "Jeans"}, "87")
await _flush(hass)
assert coordinator.data["cycle"] == "cloud:6B"
hass.config_entries.async_update_entry(entry, options={CONF_CLOUD_COURSES_ENABLED: False})
coordinator._on_cloud_courses_changed()
assert coordinator.data["cycle"] == "87"
async def test_an_unrelated_option_save_before_first_poll_does_not_clear_a_real_repair(
hass: HomeAssistant,
):
"""_on_cloud_courses_changed now runs from __init__.py's options-update
listener on *every* entry save, not just a cloud-course-specific one --
including one made before this device's first poll (a fresh restart,
still rehydrating). /course/vs/0 unpolled reads as an empty rep, which
must not be treated as evidence that nothing is pending: that would
delete a Repair a real poll had every reason to raise."""
entry = _entry(hass)
coordinator = await _coordinator(hass, entry)
coordinator._refresh_cloud_course_issue()
await _flush(hass)
registry = ir.async_get(hass)
issue_id = f"cloud_courses_{entry.entry_id}"
assert registry.async_get_issue(DOMAIN, issue_id) is not None
# A second coordinator against the same entry, standing in for a
# restart that hasn't polled yet -- its own resource cache is empty.
fresh = LocalThingsCoordinator(hass, entry)
fresh._on_cloud_courses_changed()
await _flush(hass)
assert registry.async_get_issue(DOMAIN, issue_id) is not None
# ---------------------------------------------------------------------------
# Options flow
# ---------------------------------------------------------------------------