fix(media_player): D1 — browse builds real BrowseMedia nodes, not BrowseMediaSource (t37)
`homeassistant.components.media_player` has not exported `BrowseMediaSource` for years: the class lives in `homeassistant.components.media_source.models`, where it exists to build `media_source://<domain>` ids and is the only place a `domain` parameter belongs (t30 G18's note was about THAT class). The local import therefore raised ImportError, and with it every real browse failed — while the suite stayed green because the harness stubbed a `BrowseMediaSource` that real HA does not expose from there. `async_browse_media` now builds the standard tree exactly as core's own androidtv_remote/media_player.py does: `BrowseMedia` imported from homeassistant.components.media_player at module scope, every node carrying an explicit media_content_id/media_content_type (MediaType.APP/APPS), `children_media_class=MediaClass.APP` on the directory, and no `domain`. Verified against a real install: the old import raises ImportError, the old `domain=` kwarg raises TypeError, and the new node constructs. The harness is now faithful so this cannot hide again: * `entity_stublib` exposes a `BrowseMedia` with the real keyword-only signature, `as_dict()` and `calculate_children_class()`, plus `MediaType`, and deliberately NOT `BrowseMediaSource` — so both the drifted import and the drifted kwarg fail in-suite; * the app-directory test asserts the standard shape (classes, explicit ids and types, children_media_class, the serialised payload) instead of the removed `domain` attribute; * TestFaithfulHaStubs pins the harness contract on the AST (no production module imports BrowseMediaSource) rather than on prose. Verified: 284 passed, 2 skipped (D1 stage); ruff clean. Full gate re-run on the completed branch: 289 passed / 2 skipped and 290 passed / 1 skipped.
This commit is contained in:
1 parent
5ede4fe722
commit
37b2accfda
3 files changed
+160
-28
No files matched your search
@@ -6,11 +6,13 @@ legacy external entity, plus the §7 channel digit sequence (protocol digits,
|
||||
layer marks which tier served each command.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
import entity_stublib
|
||||
import pytest
|
||||
from conftest import _SHIELD_DIR
|
||||
from entity_stublib import ENTITIES_STUB_NAMES, install, load_entity_modules
|
||||
from nsh.connection import ShieldConnectionManager
|
||||
from nsh.const import TIVIMATE_PACKAGE
|
||||
@@ -1014,6 +1016,52 @@ class TestEntryDiagnostics:
|
||||
assert payload["transport"]["protocol"]["active"] is not None
|
||||
|
||||
|
||||
class TestFaithfulHaStubs:
|
||||
"""t37: the harness mirrors WHERE real HA exposes its API.
|
||||
|
||||
A regression of the exact drifts this task repairs must fail IN-SUITE, not
|
||||
hide behind a stub that exposes anything anywhere.
|
||||
"""
|
||||
|
||||
def test_browse_media_source_is_not_importable_from_media_player(self):
|
||||
import homeassistant.components.media_player as mp
|
||||
|
||||
assert isinstance(mp.BrowseMedia, type) # the real node class
|
||||
assert not hasattr(mp, "BrowseMediaSource")
|
||||
with pytest.raises(ImportError):
|
||||
# the drifted import must fail here exactly as in a real install
|
||||
from homeassistant.components.media_player import BrowseMediaSource # noqa: F401
|
||||
|
||||
def test_no_production_module_imports_browse_media_source(self):
|
||||
"""t37 D1: the drift is an IMPORT — checked on the AST, not on prose."""
|
||||
offenders = []
|
||||
for path in sorted(_SHIELD_DIR.glob("*.py")):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and any(
|
||||
alias.name == "BrowseMediaSource" for alias in node.names
|
||||
):
|
||||
offenders.append(f"{path.name}:{node.lineno} imports it")
|
||||
if isinstance(node, ast.Import) and any(
|
||||
alias.name.endswith("BrowseMediaSource") for alias in node.names
|
||||
):
|
||||
offenders.append(f"{path.name}:{node.lineno} imports it")
|
||||
assert offenders == []
|
||||
|
||||
def test_the_browse_nodes_refuse_the_media_source_domain_kwarg(self):
|
||||
import homeassistant.components.media_player as mp
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
mp.BrowseMedia(
|
||||
title="x",
|
||||
media_class=mp.MediaClass.APP,
|
||||
media_content_type=mp.MediaType.APP,
|
||||
media_content_id="pkg",
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
domain="nvidia_shield", # G18 keyword of BrowseMediaSource only
|
||||
)
|
||||
|
||||
class TestBrowseMediaDirectory:
|
||||
"""P4-2c: the app directory root and its launch path."""
|
||||
|
||||
@@ -1034,10 +1082,24 @@ class TestBrowseMediaDirectory:
|
||||
("com.netflix.ninja", "Netflix"),
|
||||
]
|
||||
assert all(c.media_content_type == "app" for c in root.children)
|
||||
# t30 G18 (2026-05-20 blog): the domain parameter is present at
|
||||
# every level of the tree
|
||||
assert root.domain == "nvidia_shield"
|
||||
assert all(c.domain == "nvidia_shield" for c in root.children)
|
||||
# t37 D1: a standard media_player.BrowseMedia tree — explicit
|
||||
# media_content_id/-type at every level, the directory naming the class
|
||||
# of its children, and NO media_source `domain` (that parameter belongs
|
||||
# to media_source.models.BrowseMediaSource, which also builds the
|
||||
# media_source:// ids).
|
||||
assert root.media_class == "directory"
|
||||
assert root.media_content_id == "apps"
|
||||
assert root.media_content_type == "apps"
|
||||
assert root.children_media_class == "app"
|
||||
assert not hasattr(root, "domain")
|
||||
assert [c.media_class for c in root.children] == ["app", "app"]
|
||||
# ...and it serialises into the payload the frontend consumes
|
||||
payload = root.as_dict()
|
||||
assert payload["media_content_id"] == "apps"
|
||||
assert payload["children_media_class"] == "app"
|
||||
assert payload["children"][0]["media_content_id"] == "ar.tvplayer.tv"
|
||||
assert payload["children"][0]["media_content_type"] == "app"
|
||||
assert payload["children"][0]["can_play"] is True
|
||||
|
||||
async def test_root_falls_back_to_the_curated_default(self, modules):
|
||||
player, _s, _i = make_player(modules) # no apps map configured
|
||||
|
||||
Reference in new issue
Block a user