diff --git a/custom_components/spook/dashboard_extraction.py b/custom_components/spook/dashboard_extraction.py index 30f09e9c1..6d3aa8579 100644 --- a/custom_components/spook/dashboard_extraction.py +++ b/custom_components/spook/dashboard_extraction.py @@ -862,6 +862,196 @@ def extract_icons_from_dashboard_node(node: Any) -> set[str]: return icons +# Whatever a template or a card's own variables turn into is not known until +# they run. Jinja (a comment too), button-card's `[[[ ]]]`, decluttering-card's +# `[[ ]]` and config-template-card's `${ }`. Only the opening is asked for: a +# half-written one is nothing to judge either. +TEMPLATE_MARKERS = ("{{", "{%", "{#", "[[", "${") + +# The core cards that put on the theme their `theme` names, each with its own +# call to `applyThemesOnElement` in `src/panels/lovelace/cards`. The entity +# button and the sensor card are the button and the entity card under another +# name, the shopping list is the to-do list. Every other card leaves the key +# alone, so a name there that is gone changes nothing. +_CARDS_WITH_THEME = frozenset( + { + "alarm-panel", + "button", + "calendar", + "entities", + "entity", + "entity-button", + "gauge", + "glance", + "humidifier", + "light", + "logbook", + "markdown", + "media-control", + "picture", + "picture-elements", + "picture-entity", + "picture-glance", + "plant-status", + "sensor", + "shopping-list", + "statistic", + "thermostat", + "todo-list", + "weather-forecast", + } +) + +# The core cards that put the cards in their `cards` on the dashboard. Any +# other card leaves such a key alone. +_CARDS_WITH_CARDS = frozenset({"grid", "horizontal-stack", "vertical-stack"}) + +# The conditional card puts the one card in its `card` on the dashboard. +_CONDITIONAL_CARD = "conditional" + +# The entity filter card hands its rows to an entities card, unless its own +# `card` names another type. +_ENTITY_FILTER_CARD = "entity-filter" +_ENTITY_FILTER_DEFAULT_CARD = "entities" + +# The two parts of a sections view that each hold a card. +_VIEW_PARTS_WITH_A_CARD = ("header", "footer") + +# The view layouts, as the frontend's `getViewType` picks them. The cards of a +# sections view are only shown while editing, to be moved into a section. +_MASONRY_VIEW = "masonry" +_VIEWS_WITH_CARDS = frozenset({_MASONRY_VIEW, "sidebar"}) +_PANEL_VIEW = "panel" +_SECTIONS_VIEW = "sections" + +# The one section layout the frontend draws. A section without a type gets it, +# any other core type turns into an error card that shows none of its content. +_GRID_SECTION = "grid" + + +def _view_type(view: dict[str, Any]) -> Any: + """Return the layout the frontend picks for a view. + + Its `getViewType` asks JavaScript whether each key is set, and to + JavaScript an empty list is: `sections: []` makes a sections view. + """ + if (view_type := view.get("type")) not in _JAVASCRIPT_FALSY: + return view_type + if view.get("panel") not in _JAVASCRIPT_FALSY: + return _PANEL_VIEW + if view.get("sections") not in _JAVASCRIPT_FALSY: + return _SECTIONS_VIEW + if view.get("cards") not in _JAVASCRIPT_FALSY: + return _MASONRY_VIEW + return _SECTIONS_VIEW + + +def _collect_theme(node: dict[str, Any], themes: set[str]) -> None: + """Collect the theme a dashboard node names, if it names one. + + The frontend looks the name up exactly as written, spaces and all. Only + an empty one is no theme at all, and a template is no name until it runs. + """ + if ( + isinstance(theme := node.get("theme"), str) + and theme + and not any(marker in theme for marker in TEMPLATE_MARKERS) + ): + themes.add(theme) + + +def _collect_card_themes(card: Any, themes: set[str]) -> None: + """Collect the themes a core card, and the cards in it, put on.""" + if not isinstance(card, dict) or not isinstance(card_type := card.get("type"), str): + return + + # What a custom card does with a `theme`, or with the cards it holds, is + # its own business. + if card_type.startswith(_CUSTOM_PREFIX): + return + + if card_type in _CARDS_WITH_THEME: + _collect_theme(card, themes) + + # The stacks and the grid hold cards, the conditional and the entity + # filter card hold one. + if card_type in _CARDS_WITH_CARDS: + _collect_cards_themes(card.get("cards"), themes) + elif card_type == _CONDITIONAL_CARD: + _collect_card_themes(card.get("card"), themes) + elif card_type == _ENTITY_FILTER_CARD and isinstance( + inner_card := card.get("card"), dict + ): + _collect_card_themes( + {"type": _ENTITY_FILTER_DEFAULT_CARD, **inner_card}, themes + ) + + +def _collect_cards_themes(cards: Any, themes: set[str]) -> None: + """Collect the themes a list of cards puts on.""" + if not isinstance(cards, list): + return + + for card in cards: + _collect_card_themes(card, themes) + + +def _collect_section_themes(sections: Any, themes: set[str]) -> None: + """Collect the themes a list of sections, and what is in them, put on. + + The section wrapper puts on a section's theme whatever its type, so a + custom section's own theme counts, and the cards and sections in it are + left to it. Only the grid layout draws what is in it. A strategy section + makes its config when it opens, so none of it is known up front. + """ + if not isinstance(sections, list): + return + + for section in sections: + if not isinstance(section, dict) or "strategy" in section: + continue + + _collect_theme(section, themes) + if (section.get("type") or _GRID_SECTION) == _GRID_SECTION: + _collect_cards_themes(section.get("cards"), themes) + _collect_section_themes(section.get("sections"), themes) + + +def extract_themes_from_dashboard_view(view: Any) -> set[str]: + """Return the themes a view, its sections and its core cards put on. + + The view container puts on a view's theme whatever its type, so a custom + view's own theme counts, and what is in it does not. A strategy view makes + its config when it opens, so none of it is known up front. + """ + themes: set[str] = set() + if not isinstance(view, dict) or "strategy" in view: + return themes + + _collect_theme(view, themes) + if _is_custom(view): + return themes + + # Each layout only draws its own part of the config. Whatever another + # layout would use is left lying around and changes nothing. + view_type = _view_type(view) + if view_type in _VIEWS_WITH_CARDS: + _collect_cards_themes(view.get("cards"), themes) + elif view_type == _PANEL_VIEW and isinstance(cards := view.get("cards"), list): + # A panel view shows its first card, and only warns about the rest. + _collect_cards_themes(cards[:1], themes) + elif view_type == _SECTIONS_VIEW: + _collect_section_themes(view.get("sections"), themes) + if isinstance(sidebar := view.get("sidebar"), dict): + _collect_section_themes(sidebar.get("sections"), themes) + + for part in _VIEW_PARTS_WITH_A_CARD: + if isinstance(holder := view.get(part), dict): + _collect_card_themes(holder.get("card"), themes) + + return themes + + # What `Number()` and `trim()` in JavaScript take for whitespace: tab, line # and form feeds, the byte order mark, every space separator and the line # terminators. Python's own `\s` and `strip()` take a few more, like the diff --git a/custom_components/spook/ectoplasms/lovelace/repairs/unknown_theme_references.py b/custom_components/spook/ectoplasms/lovelace/repairs/unknown_theme_references.py new file mode 100644 index 000000000..85b4f4f68 --- /dev/null +++ b/custom_components/spook/ectoplasms/lovelace/repairs/unknown_theme_references.py @@ -0,0 +1,126 @@ +"""Spook - Your homie.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.frontend import DATA_THEMES, DEFAULT_THEME, VALUE_NO_THEME +from homeassistant.components.lovelace import DOMAIN +from homeassistant.const import ( + EVENT_COMPONENT_LOADED, + EVENT_LOVELACE_UPDATED, + EVENT_THEMES_UPDATED, +) +from homeassistant.core import callback + +from ....const import LOGGER +from ....dashboard_extraction import extract_themes_from_dashboard_view +from ....repairs import AbstractSpookRepair +from ..dashboards import async_dashboard_configs + +if TYPE_CHECKING: + from homeassistant.components.lovelace.dashboard import ( + LovelaceStorage, + LovelaceYAML, + ) + +# The frontend's own look, built in and never in the list of themes, and the +# name Home Assistant itself uses for no theme at all. +_BUILT_IN_THEMES = frozenset({DEFAULT_THEME, VALUE_NO_THEME}) + + +class SpookRepair(AbstractSpookRepair): + """Spook repair tries to find themes in dashboards that do not exist. + + The frontend only puts on a theme it has. A card or view naming one that + was renamed or removed gets the default look, and nothing says why. + """ + + domain = DOMAIN + repair = "lovelace_unknown_theme_references" + inspect_events = { + EVENT_COMPONENT_LOADED, + EVENT_LOVELACE_UPDATED, + EVENT_THEMES_UPDATED, + } + inspect_config_entry_changed = True + inspect_on_reload = True + automatically_clean_up_issues = True + + _dashboards: dict[str | None, LovelaceStorage | LovelaceYAML] + + async def async_activate(self) -> None: + """Handle the activating a repair.""" + self._dashboards = self.hass.data["lovelace"].dashboards + await super().async_activate() + + async def async_inspect(self) -> None: + """Trigger a inspection.""" + LOGGER.debug("Spook is inspecting: %s", self.repair) + + # Without the frontend there is no list of themes to hold anything + # against, and nothing that puts one on either. Home Assistant sets it + # up before anything else, so this is a guard, not a waiting room. + if (known_themes := self.hass.data.get(DATA_THEMES)) is None: + return + + async for dashboard, url_path, config in async_dashboard_configs( + self._dashboards + ): + self.possible_issue_ids.add(url_path) + if config is None: + continue + + themes = self.__async_extract_themes(config) + unknown_themes = { + theme + for theme in themes + if theme not in known_themes and theme not in _BUILT_IN_THEMES + } + if not unknown_themes: + continue + + first_view_path = next( + view_path + for theme, view_path in themes.items() + if theme in unknown_themes + ) + title = "Overview" + if dashboard.config: + title = dashboard.config.get("title", url_path) + self.async_create_issue( + issue_id=url_path, + references=unknown_themes, + translation_placeholders={ + "themes": "\n".join( + f"- `{theme}`" for theme in sorted(unknown_themes) + ), + "dashboard": title, + "edit": f"/{url_path}/{first_view_path}?edit=1", + }, + ) + + @callback + def __async_extract_themes(self, config: Any) -> dict[str, int | str]: + """Extract the themes a dashboard puts on, keyed by their view path. + + A dashboard run by a strategy has no views stored, only the strategy, + and the views it makes are not known until it opens. + """ + themes: dict[str, int | str] = {} + if ( + not isinstance(config, dict) + or "strategy" in config + or not isinstance(views := config.get("views"), list) + ): + return themes + + for view_index, view in enumerate(views): + if not isinstance(view, dict): + continue + + view_path: int | str = view.get("path") or view_index + for theme in sorted(extract_themes_from_dashboard_view(view)): + themes.setdefault(theme, view_path) + + return themes diff --git a/custom_components/spook/ectoplasms/lovelace/repairs/unknown_view_references.py b/custom_components/spook/ectoplasms/lovelace/repairs/unknown_view_references.py index 053246785..8d7c0ae37 100644 --- a/custom_components/spook/ectoplasms/lovelace/repairs/unknown_view_references.py +++ b/custom_components/spook/ectoplasms/lovelace/repairs/unknown_view_references.py @@ -13,6 +13,7 @@ from ....const import LOGGER from ....dashboard_extraction import ( + TEMPLATE_MARKERS, extract_navigation_paths_from_dashboard_node, javascript_number, ) @@ -33,12 +34,6 @@ # before it looks for a view by that name. _UNUSED_ENTITIES_VIEW = "hass-unused-entities" -# Whatever a template or a card's own variables turn into is not known until -# they run. Jinja (a comment too), button-card's `[[[ ]]]`, decluttering-card's -# `[[ ]]` and config-template-card's `${ }`. Only the opening is asked for: a -# half-written one is no path to judge either. -_TEMPLATE_MARKERS = ("{{", "{%", "{#", "[[", "${") - # The browser drops these from a URL, or reads them as a slash, before the # frontend gets to see the path. What it ends up as is the browser's call. _REWRITTEN_BY_THE_BROWSER = ("\t", "\n", "\r", "\\") @@ -54,7 +49,7 @@ def _dashboard_and_view(path: str) -> tuple[str, str] | None: if ( not path.startswith("/") or path != path.strip() - or any(marker in path for marker in _TEMPLATE_MARKERS) + or any(marker in path for marker in TEMPLATE_MARKERS) or any(character in path for character in _REWRITTEN_BY_THE_BROWSER) ): return None diff --git a/custom_components/spook/repair_documentation.py b/custom_components/spook/repair_documentation.py index 06c7973d9..0071363b8 100644 --- a/custom_components/spook/repair_documentation.py +++ b/custom_components/spook/repair_documentation.py @@ -48,6 +48,7 @@ "lovelace_unknown_entity_references": "lovelace#unknown-referenced-entities", "lovelace_unknown_icons": "lovelace#unknown-icons", "lovelace_unknown_service_references": "lovelace#unknown-actions", + "lovelace_unknown_theme_references": "lovelace#unknown-themes", "lovelace_unknown_user_references": "lovelace#removed-users", "lovelace_unknown_view_references": "lovelace#unknown-views", "lovelace_unknown_state_references": "lovelace#unknown-referenced-states", diff --git a/custom_components/spook/translations/en.json b/custom_components/spook/translations/en.json index ec07a1b24..d2a3e038a 100644 --- a/custom_components/spook/translations/en.json +++ b/custom_components/spook/translations/en.json @@ -1111,6 +1111,10 @@ "description": "Spook has found a ghost in your dashboards 👻\n\nWhile floating around, Spook crossed path with the following dashboard:\n\n[{dashboard}]({edit})\n\nThis dashboard navigates to the following views, which do not exist:\n\n{paths}\n\n\n\nA button or card navigating to one of these opens the first view of that dashboard instead, without saying so. This often happens when a view was renamed, moved or removed. To fix this error, [edit the dashboard]({edit}) and point these to a view that exists.\n\nSpook 👻 Your homie.", "title": "Unknown views navigated to in dashboard: {dashboard}" }, + "lovelace_unknown_theme_references": { + "description": "Spook has found a ghost in your dashboards 👻\n\nWhile floating around, Spook crossed path with the following dashboard:\n\n[{dashboard}]({edit})\n\nThis dashboard uses the following themes, which do not exist:\n\n{themes}\n\n\n\nA card, section or view with one of these themes gets the default look instead, without saying so. This often happens when a theme was renamed or removed. To fix this error, [edit the dashboard]({edit}) and pick a theme that exists, or add the theme back.\n\nSpook 👻 Your homie.", + "title": "Unknown themes used in dashboard: {dashboard}" + }, "min_max_unknown_sources": { "title": "Unknown members used in min/max helper: {helper}", "fix_flow": { diff --git a/documentation/integrations/lovelace.md b/documentation/integrations/lovelace.md index 9a25ca28f..f9eb01cf5 100644 --- a/documentation/integrations/lovelace.md +++ b/documentation/integrations/lovelace.md @@ -165,6 +165,26 @@ To resolve the raised issue, edit the dashboard and pick the users that should s - A dashboard run by a strategy, like the areas dashboard, is not checked. ::: +### Unknown themes + +Dashboards are inspected for the themes they use: the theme of a view, of a section, and of the cards that take one, also inside stacks, grids, conditional cards and the header and footer of a sections view. When that theme does not exist, Spook raises a repair issue naming the dashboard and the themes that are missing. + +The frontend does not tell you when a theme is missing. It only puts on a theme it has, so a card or view asking for one that is gone gets the default look, and nothing says why. This usually happens when a theme was renamed, or the file it came from was removed. + +A theme is found by its name, exactly as written, so `midnight` is not `Midnight`. `default` and `none` are fine: those are built in, not themes of your own. Spook looks again when the themes are reloaded, so adding a theme back clears the issue as well. + +To resolve the raised issue, edit the dashboard and pick a theme that exists, or add the theme back. Spook will automatically remove the repair issue once the issue is fixed. + +:::{attention} Known limitations +:class: dropdown + +- Only the cards that put on a theme are checked. A theme on any other card, like a tile card or a stack, does nothing either way. +- Only what the view's layout draws is checked. A panel view shows its first card only, and the sections, header and footer of a view that is not a sections view are never shown. +- Custom cards, and the cards inside them, are not checked. What a custom card does with a `theme` is up to the card. The theme of a custom view or section is checked, as the frontend puts that one on itself. +- A theme that is a template, or uses a card's own variables, is not checked. +- Views, sections and dashboards run by a strategy are not checked. Those are only made when they open. + ::: + ### Missing dashboard resources Dashboard resources tell Home Assistant which extra JavaScript and CSS files to load, which is how custom cards get there. Spook checks the ones it can: a resource served from `/local/` or `/hacsfiles/` maps to a file on disk, so Spook can see whether that file is there. If it is not, it will raise a repair issue listing the resources in question. diff --git a/plugins/spook/skills/spook/references/repairs.md b/plugins/spook/skills/spook/references/repairs.md index f6c3d1413..28a3944a4 100644 --- a/plugins/spook/skills/spook/references/repairs.md +++ b/plugins/spook/skills/spook/references/repairs.md @@ -36,6 +36,7 @@ Every kind of issue Spook raises in Repairs, under the `spook` domain. An issue - `lovelace_unknown_icons`: "Unknown icons used in dashboard: {dashboard}". This dashboard uses the following icons, which do not exist in the Material Design Icons that come with Home Assistant (version {version}). Documentation: https://spook.boo/lovelace#unknown-icons - `lovelace_unknown_service_references`: "Unknown actions used in dashboard: {dashboard}". This dashboard performs the following actions, which are unknown to Home Assistant. Documentation: https://spook.boo/lovelace#unknown-actions - `lovelace_unknown_state_references`: "Unknown states used in dashboard: {dashboard}". This dashboard checks for the following states, which their entities are never in. Not as something that kind of entity can be, not among the options the entity offers, and not anywhere in what the recorder remembers of them. Documentation: https://spook.boo/lovelace#unknown-referenced-states +- `lovelace_unknown_theme_references`: "Unknown themes used in dashboard: {dashboard}". This dashboard uses the following themes, which do not exist. Documentation: https://spook.boo/lovelace#unknown-themes - `lovelace_unknown_user_references`: "Removed users named in dashboard: {dashboard}". This dashboard names the following users, which no longer exist. Documentation: https://spook.boo/lovelace#removed-users - `lovelace_unknown_view_references`: "Unknown views navigated to in dashboard: {dashboard}". This dashboard navigates to the following views, which do not exist. Documentation: https://spook.boo/lovelace#unknown-views - `min_max_unknown_sources`: "Unknown members used in min/max helper: {helper}". The min/max helper {helper} (`{entity_id}`) combines the following members, which are unknown to Home Assistant. Fix offers: Remove these members; Let me fix it myself; Keep them, stop telling me. Documentation: https://spook.boo/homeassistant#unknown-min-max-helper-members diff --git a/tests/ectoplasms/lovelace/repairs/test_unknown_theme_references.py b/tests/ectoplasms/lovelace/repairs/test_unknown_theme_references.py new file mode 100644 index 000000000..7f303174a --- /dev/null +++ b/tests/ectoplasms/lovelace/repairs/test_unknown_theme_references.py @@ -0,0 +1,780 @@ +"""Tests for the Lovelace unknown themes repair.""" + +# pylint: disable=protected-access,wrong-import-order +from __future__ import annotations + +from datetime import timedelta +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any +from unittest.mock import patch + +from pytest_homeassistant_custom_component.common import async_fire_time_changed + +from homeassistant.components.frontend import DATA_THEMES +from homeassistant.components.lovelace.const import ConfigNotFound +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from custom_components.spook.ectoplasms.lovelace.repairs.unknown_theme_references import ( + SpookRepair, +) +import pytest +from tests.repair_helpers import async_issue_about + +if TYPE_CHECKING: + from pathlib import Path + + from homeassistant.core import HomeAssistant + from homeassistant.helpers import issue_registry as ir + +_ISSUE = "lovelace_unknown_theme_references_lovelace" + +# The themes every test starts with, as they would be in `configuration.yaml`. +# One light and dark, one dark only, so the variants are covered too. +_THEMES = { + "Midnight": {"primary-color": "#000033"}, + "Sunrise": { + "modes": { + "light": {"primary-color": "#ffaa00"}, + "dark": {"primary-color": "#aa5500"}, + }, + }, +} + + +@pytest.fixture(autouse=True) +async def frontend_with_themes(hass: HomeAssistant, tmp_path: Path) -> None: + """Set up the frontend with its themes, the way Home Assistant does. + + The frontend's files are not installed for the tests, so it is pointed at + an empty development checkout instead. Themes do not care either way. + """ + (tmp_path / "hass_frontend").mkdir() + assert await async_setup_component( + hass, + "frontend", + {"frontend": {"development_repo": str(tmp_path), "themes": _THEMES}}, + ) + + +def _dashboard(config: dict[str, Any] | None) -> SimpleNamespace: + """Return the default dashboard with this config, or none stored.""" + + async def async_load(*, force: bool) -> dict[str, Any]: + """Return the dashboard config.""" + del force + if config is None: + raise ConfigNotFound + return config + + return SimpleNamespace( + url_path="lovelace", config={"title": "Overview"}, async_load=async_load + ) + + +def _with(*cards: Any, **view: Any) -> dict[str, Any]: + """Return a dashboard with these cards on its second view.""" + return { + "views": [ + {"path": "home", "cards": []}, + {"path": "night", "cards": list(cards), **view}, + ] + } + + +def _bulleted(*themes: str) -> str: + """Return the list of themes as the issue puts it.""" + return "\n".join(f"- `{theme}`" for theme in themes) + + +async def _async_settle(hass: HomeAssistant) -> None: + """Let the repair's debouncer run what was asked of it.""" + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + + +async def _async_activate(hass: HomeAssistant, config: dict[str, Any]) -> SpookRepair: + """Activate the repair on a house with one dashboard, and let it look.""" + hass.data["lovelace"] = SimpleNamespace(dashboards={"lovelace": _dashboard(config)}) + + repair = SpookRepair(hass) + await repair.async_activate() + await _async_settle(hass) + return repair + + +async def _async_reported( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + config: dict[str, Any], +) -> str | None: + """Return the themes the issue on this dashboard lists, if there is one. + + The look is run straight rather than through the debouncer, so a look + that trips over something fails the test instead of only being logged. + """ + repair = SpookRepair(hass) + repair._dashboards = {"lovelace": _dashboard(config)} # noqa: SLF001 + await repair._async_inspect_with_cleanup() # noqa: SLF001 + + if (issue := async_issue_about(issue_registry, _ISSUE)) is None: + return None + assert issue.translation_placeholders + return issue.translation_placeholders["themes"] + + +async def test_card_with_a_missing_theme_creates_an_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a card naming a theme that is gone is reported, by the view it is on.""" + # A theme that exists on the first view, so the edit link has to skip it. + config = _with({"type": "entities", "theme": "Midnight Blue", "entities": []}) + config["views"][0]["cards"] = [ + {"type": "entities", "theme": "Midnight", "entities": []} + ] + repair = await _async_activate(hass, config) + await repair.async_deactivate() + + issue = async_issue_about(issue_registry, _ISSUE) + assert issue + assert issue.translation_key == "lovelace_unknown_theme_references" + assert issue.translation_placeholders == { + "themes": "- `Midnight Blue`", + "dashboard": "Overview", + "edit": "/lovelace/night?edit=1", + } + + +async def test_every_card_that_puts_on_a_theme_is_checked( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test each core card that calls `applyThemesOnElement` is read.""" + # Each card type next to the frontend card whose `applyThemesOnElement` + # call puts on its theme. Written out here, not taken from the code, so + # a card dropped there fails this test. + theme_applied_by = { + "alarm-panel": "hui-alarm-panel-card", + "button": "hui-button-card", + "calendar": "hui-calendar-card", + "entities": "hui-entities-card", + "entity": "hui-entity-card", + "entity-button": "hui-button-card", + "gauge": "hui-gauge-card", + "glance": "hui-glance-card", + "humidifier": "hui-humidifier-card", + "light": "hui-light-card", + "logbook": "hui-logbook-card", + "markdown": "hui-markdown-card", + "media-control": "hui-media-control-card", + "picture": "hui-picture-card", + "picture-elements": "hui-picture-elements-card", + "picture-entity": "hui-picture-entity-card", + "picture-glance": "hui-picture-glance-card", + "plant-status": "hui-plant-status-card", + "sensor": "hui-entity-card", + "shopping-list": "hui-todo-list-card", + "statistic": "hui-statistic-card", + "thermostat": "hui-thermostat-card", + "todo-list": "hui-todo-list-card", + "weather-forecast": "hui-weather-forecast-card", + } + + assert await _async_reported( + hass, + issue_registry, + _with(*({"type": kind, "theme": f"Gone {kind}"} for kind in theme_applied_by)), + ) == "\n".join(f"- `Gone {kind}`" for kind in sorted(theme_applied_by)) + + +async def test_cards_that_ignore_a_theme_are_left_alone( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a theme on a card that never puts one on is not reported. + + The tile card and the stacks have no theme to put on, so a name there + that is gone changes nothing. + """ + assert ( + await _async_reported( + hass, + issue_registry, + _with( + {"type": "tile", "entity": "light.bed", "theme": "Gone"}, + {"type": "vertical-stack", "theme": "Gone", "cards": []}, + {"type": "heading", "theme": "Gone"}, + ), + ) + is None + ) + + +async def test_cards_only_containers_put_on_are_left_alone( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a `cards` or `card` key on a card that holds no cards is not read. + + Only the stacks, the grid, the conditional and the entity filter card put + on the cards they hold. Any other card leaves such a key alone. + """ + assert ( + await _async_reported( + hass, + issue_registry, + _with( + { + "type": "entities", + "entities": [], + "cards": [{"type": "markdown", "theme": "Gone"}], + "card": {"type": "markdown", "theme": "Gone"}, + }, + { + "type": "grid", + "cards": [], + "card": {"type": "markdown", "theme": "Gone"}, + }, + { + "type": "conditional", + "conditions": [], + "cards": [{"type": "markdown", "theme": "Gone"}], + }, + ), + ) + is None + ) + + +async def test_view_and_section_themes_are_checked( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the view's own theme, its sections and its sidebar's are read.""" + config = { + "views": [ + {"path": "home", "cards": []}, + { + "path": "night", + "type": "sections", + "theme": "Gone view", + "sections": [ + { + "type": "grid", + "theme": "Gone section", + "cards": [{"type": "markdown", "theme": "Gone card"}], + }, + # No type is a grid section. + {"theme": "Gone untyped section", "cards": []}, + ], + "sidebar": { + "sections": [{"type": "grid", "theme": "Gone sidebar section"}] + }, + }, + ] + } + + assert await _async_reported(hass, issue_registry, config) == _bulleted( + "Gone card", + "Gone section", + "Gone sidebar section", + "Gone untyped section", + "Gone view", + ) + + +async def test_sections_inside_a_section_are_checked( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a section in a section, and its cards, are read like any other.""" + config = { + "views": [ + { + "path": "home", + "type": "sections", + "sections": [ + { + "type": "grid", + "sections": [ + { + "type": "grid", + "theme": "Gone inner section", + "cards": [ + {"type": "markdown", "theme": "Gone inner card"} + ], + }, + {"strategy": {"type": "x"}, "theme": "Strategy"}, + ], + }, + { + "type": "custom:my-section", + "sections": [{"type": "grid", "theme": "Custom's own"}], + }, + ], + }, + ] + } + + assert await _async_reported(hass, issue_registry, config) == _bulleted( + "Gone inner card", + "Gone inner section", + ) + + +async def test_only_what_the_view_layout_draws_is_checked( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test leftovers of another view layout are left alone. + + A panel view draws its first card. A masonry or sidebar view draws no + sections, header or footer. A sections view only shows its loose cards + while editing, to be moved into a section. + """ + leftovers = { + "sections": [{"type": "grid", "theme": "Leftover section"}], + "sidebar": {"sections": [{"type": "grid", "theme": "Leftover sidebar"}]}, + "header": {"card": {"type": "markdown", "theme": "Leftover header"}}, + "footer": {"card": {"type": "markdown", "theme": "Leftover footer"}}, + } + config = { + "views": [ + { + "path": "home", + "panel": True, + "cards": [ + {"type": "markdown", "theme": "Gone panel card"}, + {"type": "markdown", "theme": "Second panel card"}, + ], + **leftovers, + }, + { + "path": "masonry", + "type": "masonry", + "cards": [{"type": "markdown", "theme": "Gone masonry card"}], + **leftovers, + }, + { + "path": "sidebar", + "type": "sidebar", + "cards": [{"type": "markdown", "theme": "Gone sidebar card"}], + **leftovers, + }, + { + "path": "sections", + "type": "sections", + "sections": [], + "cards": [{"type": "markdown", "theme": "Imported card"}], + }, + # No type, and an empty list of sections: to the frontend that + # is a sections view, so the loose cards are only imported ones. + { + "path": "untyped", + "sections": [], + "cards": [{"type": "markdown", "theme": "Imported untyped card"}], + }, + { + "path": "unknown", + "type": "no-such-layout", + "cards": [{"type": "markdown", "theme": "Error card"}], + **leftovers, + }, + ] + } + + assert await _async_reported(hass, issue_registry, config) == _bulleted( + "Gone masonry card", + "Gone panel card", + "Gone sidebar card", + ) + + +async def test_nested_cards_are_checked( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test cards inside stacks, grids, conditionals and headers are read.""" + config = { + "views": [ + { + "path": "home", + "cards": [ + { + "type": "vertical-stack", + "cards": [ + { + "type": "grid", + "cards": [{"type": "button", "theme": "Gone grid"}], + } + ], + }, + { + "type": "conditional", + "conditions": [], + "card": {"type": "gauge", "theme": "Gone conditional"}, + }, + { + "type": "horizontal-stack", + "cards": [{"type": "light", "theme": "Gone horizontal"}], + }, + ], + }, + { + "path": "night", + "sections": [], + "header": {"card": {"type": "markdown", "theme": "Gone header"}}, + "footer": {"card": {"type": "markdown", "theme": "Gone footer"}}, + }, + ] + } + + repair = await _async_activate(hass, config) + await repair.async_deactivate() + + issue = async_issue_about(issue_registry, _ISSUE) + assert issue + assert issue.translation_placeholders + assert issue.translation_placeholders["themes"] == _bulleted( + "Gone conditional", + "Gone footer", + "Gone grid", + "Gone header", + "Gone horizontal", + ) + assert issue.translation_placeholders["edit"] == "/lovelace/home?edit=1" + + +async def test_entity_filter_card_hands_its_rows_to_an_entities_card( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test the entity filter's inner card is an entities card without a type.""" + assert ( + await _async_reported( + hass, + issue_registry, + _with( + { + "type": "entity-filter", + "entities": [], + "card": {"theme": "Gone untyped"}, + }, + { + "type": "entity-filter", + "entities": [], + "card": {"type": "glance", "theme": "Gone glance"}, + }, + ), + ) + == "- `Gone glance`\n- `Gone untyped`" + ) + + +@pytest.mark.parametrize( + "theme", + [ + # The frontend's own look. + "default", + # Home Assistant's name for no theme at all. + "none", + # Themes that exist, with and without light and dark variants. + "Midnight", + "Sunrise", + # No theme at all. + "", + None, + 42, + ], +) +async def test_themes_that_are_there_are_fine( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + theme: Any, +) -> None: + """Test built in names, known themes and no theme are not reported.""" + assert ( + await _async_reported( + hass, + issue_registry, + _with({"type": "entities", "theme": theme, "entities": []}, theme=theme), + ) + is None + ) + + +@pytest.mark.parametrize( + "theme", + [ + # The frontend looks a theme up exactly as written. + "midnight", + "Midnight ", + " ", + "Default", + "None", + # The variants are modes inside a theme, never a theme of their own. + "Sunrise dark", + ], +) +async def test_a_name_off_by_a_little_is_reported( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + theme: str, +) -> None: + """Test a name the frontend does not find as written is reported.""" + assert ( + await _async_reported( + hass, + issue_registry, + _with({"type": "entities", "theme": theme, "entities": []}), + ) + == f"- `{theme}`" + ) + + +@pytest.mark.parametrize( + "theme", + [ + "{{ 'Midnight' if is_state('sun.sun', 'below_horizon') else 'Day' }}", + "{% if night %}Midnight{% endif %}", + "[[[ return 'Midnight' ]]]", + "[[theme]]", + "${vars.theme}", + ], +) +async def test_templates_are_left_alone( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + theme: str, +) -> None: + """Test a theme that is a template is not judged.""" + assert ( + await _async_reported( + hass, + issue_registry, + _with({"type": "entities", "theme": theme, "entities": []}, theme=theme), + ) + is None + ) + + +async def test_custom_cards_are_left_alone( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a custom card, and the core cards inside it, are not judged.""" + assert ( + await _async_reported( + hass, + issue_registry, + _with( + {"type": "custom:button-card", "theme": "Gone"}, + { + "type": "custom:stack-in-card", + "cards": [{"type": "entities", "theme": "Gone", "entities": []}], + }, + { + "type": "conditional", + "card": {"type": "custom:mushroom-card", "theme": "Gone"}, + }, + ), + ) + is None + ) + + +async def test_custom_views_and_sections_keep_their_own_theme( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a custom view or section has its own theme checked, not its cards. + + The frontend puts on the view's and the section's theme itself, whatever + their type. What is inside them is up to the custom element. + """ + config = { + "views": [ + { + "path": "home", + "type": "custom:grid-layout", + "theme": "Gone view", + "cards": [{"type": "entities", "theme": "Gone card", "entities": []}], + }, + { + "path": "night", + "sections": [ + { + "type": "custom:layout-section", + "theme": "Gone section", + "cards": [{"type": "markdown", "theme": "Gone inside"}], + } + ], + }, + ] + } + + assert ( + await _async_reported(hass, issue_registry, config) + == "- `Gone section`\n- `Gone view`" + ) + + +async def test_only_grid_sections_have_their_content_checked( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a section of an unknown layout keeps its theme, not its content. + + The frontend only knows the grid layout, and a section without a type + gets it. Any other type turns into an error card that draws nothing that + is in the section, but the section wrapper still puts on its theme. + """ + config = { + "views": [ + { + "path": "home", + "type": "sections", + "sections": [ + { + "theme": "Gone untyped section", + "cards": [{"type": "markdown", "theme": "Gone untyped card"}], + }, + { + "type": "masonry", + "theme": "Gone broken section", + "cards": [{"type": "markdown", "theme": "Broken card"}], + "sections": [{"type": "grid", "theme": "Broken inner"}], + }, + ], + }, + ] + } + + assert await _async_reported(hass, issue_registry, config) == _bulleted( + "Gone broken section", + "Gone untyped card", + "Gone untyped section", + ) + + +async def test_strategies_are_left_alone( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a dashboard, view or section run by a strategy is not judged.""" + strategy = {"type": "original-states"} + + assert ( + await _async_reported( + hass, issue_registry, {"strategy": strategy, "views": [{"theme": "Gone"}]} + ) + is None + ) + assert ( + await _async_reported( + hass, + issue_registry, + { + "views": [ + {"strategy": strategy, "theme": "Gone"}, + { + "sections": [ + { + "strategy": strategy, + "theme": "Gone", + "cards": [{"type": "markdown", "theme": "Gone"}], + } + ] + }, + ] + }, + ) + is None + ) + + +async def test_reloading_themes_clears_and_raises_the_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a theme added back, then removed again, is noticed on reload.""" + repair = await _async_activate( + hass, _with({"type": "entities", "theme": "Starlight", "entities": []}) + ) + assert async_issue_about(issue_registry, _ISSUE) + + with_starlight = {"frontend": {"themes": {**_THEMES, "Starlight": {}}}} + with patch( + "homeassistant.components.frontend.async_hass_config_yaml", + return_value=with_starlight, + ): + await hass.services.async_call("frontend", "reload_themes", blocking=True) + await _async_settle(hass) + assert "Starlight" in hass.data[DATA_THEMES] + assert async_issue_about(issue_registry, _ISSUE) is None + + with patch( + "homeassistant.components.frontend.async_hass_config_yaml", + return_value={"frontend": {"themes": _THEMES}}, + ): + await hass.services.async_call("frontend", "reload_themes", blocking=True) + await _async_settle(hass) + assert async_issue_about(issue_registry, _ISSUE) + + await repair.async_deactivate() + + +async def test_saving_the_dashboard_clears_the_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test fixing the theme and saving the dashboard clears the issue.""" + card = {"type": "entities", "theme": "Gone", "entities": []} + repair = await _async_activate(hass, _with(card)) + assert async_issue_about(issue_registry, _ISSUE) + + card["theme"] = "Midnight" + hass.bus.async_fire("lovelace_updated", {"url_path": None}) + await _async_settle(hass) + assert async_issue_about(issue_registry, _ISSUE) is None + + await repair.async_deactivate() + + +async def test_without_the_frontend_nothing_is_judged( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test no theme is reported when there is no list of themes to check.""" + hass.data.pop(DATA_THEMES) + + card = {"type": "entities", "theme": "Gone", "entities": []} + repair = SpookRepair(hass) + repair._dashboards = {"lovelace": _dashboard(_with(card))} # noqa: SLF001 + + # Called straight, so a look that trips over the missing list fails here + # instead of in the debouncer's log. + await repair.async_inspect() + + assert async_issue_about(issue_registry, _ISSUE) is None + + +async def test_a_dashboard_without_a_config_is_skipped( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test a dashboard with nothing stored, or stored oddly, is no problem.""" + hass.data["lovelace"] = SimpleNamespace(dashboards={"lovelace": _dashboard(None)}) + repair = SpookRepair(hass) + await repair.async_activate() + await _async_settle(hass) + await repair.async_deactivate() + assert async_issue_about(issue_registry, _ISSUE) is None + + for config in ( + {"views": "nope"}, + {"views": ["nope", {"cards": "nope", "sections": "nope", "sidebar": []}]}, + {"views": [{"header": [], "sections": ["nope", {"cards": [None]}]}]}, + ): + assert await _async_reported(hass, issue_registry, config) is None