fix(media_player): dedupe source_list case-insensitively (t53 F1)
Release / release (push) Successful in 7s
Test / test (push) Successful in 31s
Test / ha-api-drift (push) Successful in 21s

t52 F1: the dropdown collapsed exact duplicates but not names that differ only by case.
With a map of {com.a: 'Dup', com.b: 'dup'} the pre-fix tree offered ['Dup', 'dup']
(two rows) while the reverse lookup — case-insensitive by design since t47 — resolved
BOTH spellings to the first entry. So row two was a row that LIED: picking it launched
com.a, the app row one already names. Editor-reachable (two valid lines), unlike the
earlier hand-edit-only edges, which is why it is fixed rather than documented.

source_list now dedupes on name.casefold(), keeping the first spelling in the user's
typed map order. One row per name means a row can only launch the app it shows. The
shadowed package is not lost: the Browse tree still lists it under its own package id
(the t51 rule — both entries kept, each reachable by id) and select_source accepts that
id, pinned by test. The t51 policy is otherwise untouched: names stripped once, blank
name/package entries dropped, exact duplicates already collapsed, well-formed maps
byte-identical to the Browse titles with inner spaces preserved.

README: a short 'Known limits (the Apps map)' note records the two accepted boundaries
so they are known rather than latent — names differing only by case offer one row (the
shadowed app stays reachable by id), and a hand-padded map value is shown cleaned in
the dropdown and the Browse tree while app_name/source/Current App sensor display it
verbatim, so `source` can be a string that is not in source_list (a blank value falls
through to the raw package).

RED PROOF (previous tree swapped in, then restored): 4 failed / 5 passed pre-fix for
the case tests, all green after. The literal pre-fix symptom:
`assert ['Dup', 'dup'] == ['Dup']` — "Left contains one more item: 'dup'"; the
wrong-app half is pinned by `_source_package("dup") == "com.a"`, which holds on BOTH
trees and is exactly the chain that made row two launch row one's app. The new
no-duplicate invariant fails pre-fix with len(['dup', 'dup']) == 2 versus 1.

Verified: 471 passed / 2 skipped, 472 / 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); README YAML blocks still parse (5) and the t47/t48/t51
rig is green.
This commit is contained in:
James Mackay committed 2026-10-11 07:26:07 +01:00
1 parent dc3cfbbe3e
commit 0357f47284
3 files changed
+143 -8

No files matched your search

@@ -734,7 +734,10 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
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.
the user's order, so the winner does not move between rebuilds. "The same
name" is judged case-insensitively (t53), because the reverse lookup is:
names differing only by case would otherwise give the dropdown a second
row that launches the FIRST entry's app.
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
@@ -758,11 +761,26 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
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).
disagree.
Each name is offered ONCE, compared CASE-INSENSITIVELY (t53), keeping the
first spelling the user typed. Exact duplicates were already collapsed, but
``Dup``/``dup`` were not: the dropdown then showed two rows while the
reverse lookup — which is case-insensitive by design (t47) — resolved BOTH
of them to the first entry, so the second row launched the wrong app. One
row per name means a row can only launch the app it shows. The shadowed
package is not lost: the browse tree still lists it under its own id, and
select_source accepts that id.
"""
return list(dict.fromkeys(self._app_directory.values()))
seen: set[str] = set()
offered: list[str] = []
for name in self._app_directory.values():
folded = name.casefold()
if folded in seen:
continue
seen.add(folded)
offered.append(name)
return offered
@property
def source(self) -> str | None:
@@ -1106,9 +1124,12 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
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``).
matches its trimmed spelling. Names are compared case-insensitively — which
is why ``source_list`` collapses names that differ only by case (t53), so
that the row it offers resolves back to the same app it displays. When two
packages share a name the first in map order wins, the same one
``source_list`` offers (see ``_app_directory``); the shadowed package stays
reachable by passing its id.
"""
directory = self._app_directory
wanted = str(source or "").strip()