fix(media_player): normalize the app directory in one place (t51)

t49's two residuals share one cause: `_app_directory` handed the raw options map to
its consumers, so the Browse tree showed a child with a blank title and the reverse
lookup compared the user's padded name against itself. The map is hand-editable, so
the normalization belongs in that one accessor — the browse tree, source_list and the
reverse lookup then cannot disagree.

Policy, now documented in `_app_directory` (the single place):
* names are stripped as the directory is built, so display and match key are one
  string: `com.x=  TiviMate  ` shows as "TiviMate" AND resolves as "TiviMate";
* an entry whose NAME or whose PACKAGE is blank/whitespace-only is dropped, so no
  surface offers a child with no title or no package behind it (the t48 invariant,
  now extended to the Browse tree);
* two packages normalizing to the same name are BOTH KEPT — the tree reaches each by
  its own package id, so neither app is hidden — while the FIRST in map order wins the
  name: that is the single entry source_list offers and the package select_source
  resolves. Map order is the user's order, so the winner does not move between
  rebuilds; reversing the map reverses the winner, which is pinned.
* a well-formed map (what the editor and the curated default produce) is byte-identical
  in and out: stripping touches the EDGES only, so "TiviMate Pro" keeps its inner space.

source_list now just dedupes the normalized values (its own t49 filter moved into the
accessor) and `_source_package` documents the same first-wins rule.

RED PROOFS, both residuals, against the previous tree (HEAD 9761540, swapped in and
restored): 5 failed / 20 passed before, 25 passed after. The pre-fix symptoms are
concrete — the Browse tree for a map with "" and "   " entries and a blank package
returned titles ['', 'TiviMate', '   ', 'Ghost'] (an empty child, a whitespace child
and a child with no package), and a padded entry produced
source_list ['  TiviMate  ', 'Netflix'] with `_source_package("TiviMate")` returning
None (the resolution miss). After the fix both lists are ['TiviMate', 'Netflix'] and
the lookup answers. The well-formed-map test passes on BOTH trees, which is the
"unchanged for well-formed maps" half of the claim; surviving entries still launch
their key packages (protocol launch list asserted) and the curated fallback list is
untouched (its own test).

Deliberately NOT changed: `_app_name_map` (the raw map behind app_name/source and the
Current App sensor) still returns values as configured, so a padded entry can still
display padded in those two surfaces. A blank value already falls through the
resolve_app_name chain (`if mapped:`), so nothing is broken there, and stripping it
would change sensor.py's output — outside this repair's in-scope paths. Flagged in the
task report as a candidate follow-up rather than done quietly.

Verified: 463 passed / 2 skipped, 464 / 1 with androidtvremote2, ruff clean, drift
engine exit 0 under real HA (23 modules, 58 imports, 164 relative imports, 36 call
arguments, 7 awaited HA calls); the t47/t48 rig (sources, routing, command scripts,
both config-flow suites) is 259 passed.
This commit is contained in:
James Mackay committed 2026-10-11 07:21:33 +01:00
1 parent 97615408c0
commit dc3cfbbe3e
2 files changed
+160 -13

No files matched your search

+43 -11
View File
@@ -720,24 +720,49 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
ONE source for the browse tree, the source list and the reverse lookup, so
the dropdown can never offer a name the launch path cannot resolve.
The map is hand-editable, so it is NORMALIZED here — once, so that every
consumer sees the same thing (t51):
* a name is stripped, so display and match key are one string: an entry
like ``com.x= TiviMate `` shows as "TiviMate" AND resolves as
"TiviMate", instead of being shown padded and missing every lookup;
* an entry whose name — or whose package — is blank/whitespace-only is
dropped, so no surface can offer a child with no package behind it or a
name no launch can reach (the t48 invariant, extended to the tree);
* two packages normalizing to the same name are BOTH KEPT — the browse
tree reaches each by its own package id, so neither app is hidden — while
the FIRST in map order wins the name: that is the single entry
source_list offers and the package select_source resolves. Map order is
the user's order, so the winner does not move between rebuilds.
A well-formed map (what the editor and the curated default produce) passes
through byte-identical: stripping only touches the edges, so a name like
"My App" keeps its inner space.
"""
return self._entry.options.get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY
directory = self._entry.options.get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY
normalized: dict[str, str] = {}
for package, name in directory.items():
key = str(package).strip()
label = str(name).strip()
if not key or not label:
continue
normalized[key] = label
return normalized
@property
def source_list(self) -> list[str]:
"""The apps as friendly names — the dropdown a UMP replacement reads (t47).
Directory order (the user's map order, or the curated list), so rebuilding
the page does not shuffle it.
A blank or whitespace-only name is dropped (t49): the apps map is hand-editable
(``com.x=``), and an empty entry would sit in the dropdown as something to pick
that select_source then rejects as an unknown source — an option that can only
fail. The VALUES themselves are untouched, so for a well-formed map the list
stays byte-equal to the Browse tree's child titles (same accessor, same order);
names are never rewritten here, because the reverse lookup matches on them.
the page does not shuffle it. Blanks are already gone and names already
stripped by ``_app_directory`` (t49/t51) — the normalization lives in that
one accessor so the dropdown, the browse tree and the reverse lookup cannot
disagree. Each name is offered ONCE: if two packages normalize to the same
name, the first in map order is the one this list resolves to (and the
second stays reachable through the browse tree, by its package id).
"""
return [str(name) for name in self._app_directory.values() if str(name).strip()]
return list(dict.fromkeys(self._app_directory.values()))
@property
def source(self) -> str | None:
@@ -1077,7 +1102,14 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
await self._script_served("volume_set", {SCRIPT_VAR_LEVEL: volume})
def _source_package(self, source) -> str | None:
"""The package behind a source name, or a package id passed through (t47)."""
"""The package behind a source name, or a package id passed through (t47).
Both sides are normalized the same way: the query is stripped here and the
names were stripped when the directory was built (t51), so a padded entry
matches its trimmed spelling. Names are compared case-insensitively, and
when two packages share a name the first in map order wins — the same one
``source_list`` offers (see ``_app_directory``).
"""
directory = self._app_directory
wanted = str(source or "").strip()
if not wanted: