feat(config)!: migrate entries to v3 — one address, no androidtv key (t41)

ENTRY_VERSION = 3, declared once in const.py and reused as the flow's VERSION so
the stored shape and the migration cannot drift.

v2 → v3 mirrors the two shape changes:
* the duplicated address fields collapse into CONF_HOST — the protocol host
  wins, else the ADB host (both name the same Shield), reading options first and
  data second so a hand-written seed is covered too;
* the androidtv-entity key is dropped from data AND options with a logged note
  naming the dropped entity (the old value would only mislead now that nothing
  reads it).

A v1 entry still travels the whole way in one call (the v1→v2 protocol defaults
are applied, then the v3 collapse). Downgrades (version > 3) are rejected so HA
can offer a rollback, and the current version is a no-op.

Existing entries carrying the entity key keep working until the migration runs:
nothing reads it anymore (the c1 commit), which is exactly why the migration can
be a plain removal.

Test note: my first version of the version-parity pin called a bare
entity_stublib.install() and leaked the shallow stub set into the entity-module
loader of later tests (STATE_PAUSED/entity_available import failures). It now
uses the file's scoped harness, like every other test there.

Verified: 316 passed / 2 skipped, 317 / 1 with androidtvremote2, ruff clean,
drift engine exit 0 on HEAD (36 call arguments — one more awaited/checked call).
This commit is contained in:
James Mackay committed 2026-10-11 06:19:36 +01:00
1 parent 75ebeb40b0
commit 81f70585cd
5 files changed
+187 -46

No files matched your search

+46 -20
View File
@@ -9,6 +9,7 @@ migration validity for v2 entries without the field.
import asyncio # noqa: F401 (async steps need the event loop)
import gc
import logging
import sys
import types
import warnings
@@ -414,48 +415,73 @@ class TestAdbOnlyStandalone:
assert result["errors"] == {"base": "no_state_source"}
class TestMigrationKeepsOrAllowsAbsence:
async def test_v1_to_v2_keeps_the_androidtv_field(self, tmp_path):
class TestMigrationDropsTheEntityKey:
"""t41: the androidtv key is dropped from BOTH dicts with a logged note.
An entry that still carries it keeps working until then (nothing reads it) —
the migration is what removes the last trace of the entity path.
"""
async def test_v1_entry_reaches_v3_without_the_entity_key(self, tmp_path, caplog):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=1,
data={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
options={},
options={CONF_ANDROIDTV_ENTITY: "media_player.shield"},
title="mdarcy",
)
ok = await module.async_migrate_entry(hass, entry)
with caplog.at_level(logging.INFO):
ok = await module.async_migrate_entry(hass, entry)
assert ok is True
assert entry.data[CONF_ANDROIDTV_ENTITY] == "media_player.shield" # kept
# options were rewritten with the protocol defaults; the data dict —
# including the androidtv field — was never touched
assert hass.config_entries.updating_entry_calls == [
(
entry,
{
"options": {CONF_PROTOCOL_ENABLED: False, CONF_HOST: ""},
"version": 2,
},
)
]
assert CONF_ANDROIDTV_ENTITY not in entry.data
assert CONF_ANDROIDTV_ENTITY not in entry.options
assert entry.options == {CONF_PROTOCOL_ENABLED: False, CONF_HOST: ""}
assert entry.data == {}
# the note names what was dropped, so the log is actionable
notes = [record.getMessage() for record in caplog.records]
assert any("media_player.shield" in note for note in notes), notes
assert hass.config_entries.updating_entry_calls[0][1]["version"] == 3
async def test_v2_without_the_field_is_valid(self, tmp_path):
"""Standalone entries born v2 must pass through migration untouched."""
async def test_an_entry_without_the_key_logs_plainly(self, tmp_path, caplog):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(version=2, data={}, options={}, title="mdarcy")
with caplog.at_level(logging.INFO):
ok = await module.async_migrate_entry(hass, entry)
assert ok is True
notes = [record.getMessage() for record in caplog.records]
assert any("Migrated mdarcy config entry to version 3" in note for note in notes), notes
async def test_v2_entry_with_a_legacy_address_migrates_to_the_one_field(self, tmp_path):
"""An entry written before the unification lands on CONF_HOST."""
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=2,
data={},
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "shield.local"},
options={CONF_PROTOCOL_ENABLED: True, "protocol_host": "shield.local"},
title="mdarcy",
)
ok = await module.async_migrate_entry(hass, entry)
assert ok is True
assert hass.config_entries.updating_entry_calls == [] # no rewrite needed
assert entry.options[CONF_HOST] == "shield.local"
assert "protocol_host" not in entry.options
def test_the_stored_version_and_the_flow_cannot_drift(self, tmp_path):
# _form_module owns the scoped stub install/pop — never a bare
# entity_stublib.install() here: a shallow stub set leaks into the
# entity-module loader of later tests.
module = _form_module()
from nsh.const import ENTRY_VERSION
assert module.NvidiaShieldConfigFlow.VERSION == ENTRY_VERSION == 3
def _cmd_player(modules):