mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-07 20:07:26 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
543cfad738 | ||
|
|
328eb62ab1 | ||
|
|
c388068782 | ||
|
|
2b8c57481d | ||
|
|
cfa0d62afc |
@@ -7,6 +7,7 @@ import { styleMap } from "lit/directives/style-map";
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-svg-icon";
|
||||
import { updateAreaRegistryEntry } from "../../data/area/area_registry";
|
||||
@@ -14,9 +15,12 @@ import { updateDeviceRegistryEntry } from "../../data/device/device_registry";
|
||||
import {
|
||||
fetchFrontendSystemData,
|
||||
saveFrontendSystemData,
|
||||
subscribeFrontendSystemData,
|
||||
type HomeFrontendSystemData,
|
||||
type SecurityFrontendSystemData,
|
||||
} from "../../data/frontend";
|
||||
import type { LovelaceDashboardStrategyConfig } from "../../data/lovelace/config/types";
|
||||
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
|
||||
import { mdiHomeAssistant } from "../../resources/home-assistant-logo-svg";
|
||||
import type { HomeAssistant, PanelInfo, Route } from "../../types";
|
||||
import { showToast } from "../../util/toast";
|
||||
@@ -35,7 +39,7 @@ import { showNewOverviewDialog } from "./dialogs/show-dialog-new-overview";
|
||||
import { hasLegacyOverviewPanel } from "../../data/panel";
|
||||
|
||||
@customElement("ha-panel-home")
|
||||
class PanelHome extends LitElement {
|
||||
class PanelHome extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
@@ -48,12 +52,36 @@ class PanelHome extends LitElement {
|
||||
|
||||
@state() private _config: FrontendSystemData["home"] = {};
|
||||
|
||||
@state() private _securityConfig: SecurityFrontendSystemData = {};
|
||||
|
||||
@state() private _extraActionItems?: ExtraActionItem[];
|
||||
|
||||
@query(".banner") private _banner?: HTMLElement;
|
||||
|
||||
private _loadConfigPromise?: Promise<void>;
|
||||
|
||||
private _securityConfigRevision = 0;
|
||||
|
||||
public hassSubscribe() {
|
||||
return [
|
||||
subscribeFrontendSystemData(
|
||||
this.hass.connection,
|
||||
"security",
|
||||
({ value }) => {
|
||||
this._securityConfigRevision++;
|
||||
const config = value || {};
|
||||
if (deepEqual(config, this._securityConfig)) {
|
||||
return;
|
||||
}
|
||||
this._securityConfig = config;
|
||||
if (this.hasUpdated) {
|
||||
this._setLovelace();
|
||||
}
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private get _showBanner(): boolean {
|
||||
// Don't show if already dismissed
|
||||
if (this._config.welcome_banner_dismissed) {
|
||||
@@ -150,16 +178,41 @@ class PanelHome extends LitElement {
|
||||
}
|
||||
|
||||
private async _loadConfig() {
|
||||
try {
|
||||
const [_, data] = await Promise.all([
|
||||
const securityConfigRevision = this._securityConfigRevision;
|
||||
const [translationsResult, homeResult, securityResult] =
|
||||
await Promise.allSettled([
|
||||
this.hass.loadFragmentTranslation("lovelace"),
|
||||
fetchFrontendSystemData(this.hass.connection, "home"),
|
||||
fetchFrontendSystemData(this.hass.connection, "security"),
|
||||
]);
|
||||
this._config = data || {};
|
||||
} catch (err) {
|
||||
|
||||
if (translationsResult.status === "rejected") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load favorites:", err);
|
||||
console.error(
|
||||
"Failed to load Lovelace translations:",
|
||||
translationsResult.reason
|
||||
);
|
||||
}
|
||||
|
||||
if (homeResult.status === "rejected") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load home configuration:", homeResult.reason);
|
||||
this._config = {};
|
||||
} else {
|
||||
this._config = homeResult.value || {};
|
||||
}
|
||||
|
||||
if (securityResult.status === "rejected") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
"Failed to load security configuration:",
|
||||
securityResult.reason
|
||||
);
|
||||
if (securityConfigRevision === this._securityConfigRevision) {
|
||||
this._securityConfig = {};
|
||||
}
|
||||
} else if (securityConfigRevision === this._securityConfigRevision) {
|
||||
this._securityConfig = securityResult.value || {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,6 +396,7 @@ class PanelHome extends LitElement {
|
||||
return {
|
||||
strategy: {
|
||||
type: "home",
|
||||
alert_entities: this._securityConfig.alert_entities,
|
||||
favorite_entities: this._config.favorite_entities,
|
||||
home_panel: true,
|
||||
hide_welcome_message: this._config.hide_welcome_message,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { STATE_NOT_RUNNING } from "home-assistant-js-websocket";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import type { SecurityAlertEntityConfig } from "../../../../data/frontend";
|
||||
import type { ShortcutItem } from "../../../../data/home_shortcuts";
|
||||
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
|
||||
import type { LovelaceViewRawConfig } from "../../../../data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
@@ -14,11 +16,11 @@ import {
|
||||
} from "./helpers/home-summaries";
|
||||
import type { HomeAreaViewStrategyConfig } from "./home-area-view-strategy";
|
||||
import type { HomeOtherDevicesViewStrategyConfig } from "./home-other-devices-view-strategy";
|
||||
import type { ShortcutItem } from "../../../../data/home_shortcuts";
|
||||
import type { HomeOverviewViewStrategyConfig } from "./home-overview-view-strategy";
|
||||
|
||||
export interface HomeDashboardStrategyConfig {
|
||||
type: "home";
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
favorite_entities?: string[];
|
||||
home_panel?: boolean;
|
||||
hide_welcome_message?: boolean;
|
||||
@@ -103,6 +105,7 @@ export class HomeDashboardStrategy extends ReactiveElement {
|
||||
path: "overview",
|
||||
strategy: {
|
||||
type: "home-overview",
|
||||
alert_entities: config.alert_entities,
|
||||
favorite_entities: config.favorite_entities,
|
||||
home_panel: config.home_panel,
|
||||
hide_welcome_message: config.hide_welcome_message,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { floorDefaultIcon } from "../../../../components/ha-floor-icon";
|
||||
import type { AreaRegistryEntry } from "../../../../data/area/area_registry";
|
||||
import type { EnergyPreferences } from "../../../../data/energy";
|
||||
import { getEnergyPreferences } from "../../../../data/energy";
|
||||
import type { SecurityAlertEntityConfig } from "../../../../data/frontend";
|
||||
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
|
||||
import type {
|
||||
LovelaceSectionConfig,
|
||||
@@ -24,6 +25,7 @@ import type { HomeAssistant } from "../../../../types";
|
||||
import { hasClimateEntities } from "../../../climate/strategies/climate-view-strategy";
|
||||
import type {
|
||||
AreaCardConfig,
|
||||
ConditionalCardConfig,
|
||||
DiscoveredDevicesCardConfig,
|
||||
EmptyStateCardConfig,
|
||||
HeadingCardConfig,
|
||||
@@ -35,17 +37,20 @@ import type {
|
||||
UpdatesCardConfig,
|
||||
} from "../../cards/types";
|
||||
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
|
||||
import { computeSecurityAlertCardConfig } from "../../../security/strategies/security-alerts";
|
||||
import {
|
||||
LARGE_SCREEN_CONDITION,
|
||||
SMALL_SCREEN_CONDITION,
|
||||
} from "../helpers/view-columns-conditions";
|
||||
import type { LovelaceStrategyDependency } from "../types";
|
||||
import type { CommonControlsSectionStrategyConfig } from "../usage_prediction/common-controls-section-strategy";
|
||||
import { generateLovelaceSectionStrategy } from "../get-strategy";
|
||||
import { HOME_SUMMARIES_FILTERS } from "./helpers/home-summaries";
|
||||
import { OTHER_DEVICES_FILTERS } from "./helpers/other-devices-filters";
|
||||
|
||||
export interface HomeOverviewViewStrategyConfig {
|
||||
type: "home-overview";
|
||||
alert_entities?: SecurityAlertEntityConfig[];
|
||||
favorite_entities?: string[];
|
||||
home_panel?: boolean;
|
||||
hide_welcome_message?: boolean;
|
||||
@@ -256,16 +261,25 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
|
||||
let favoritesSection: LovelaceSectionRawConfig | undefined;
|
||||
if (!config.hide_suggested_entities) {
|
||||
favoritesSection = {
|
||||
strategy: {
|
||||
type: "common-controls",
|
||||
limit: maxCommonControls,
|
||||
include_entities: favoriteEntities,
|
||||
hide_empty: true,
|
||||
heading: favoritesHeadingCard,
|
||||
} satisfies CommonControlsSectionStrategyConfig,
|
||||
column_span: maxColumns,
|
||||
} satisfies LovelaceStrategySectionConfig;
|
||||
const generatedFavoritesSection = await generateLovelaceSectionStrategy(
|
||||
{
|
||||
strategy: {
|
||||
type: "common-controls",
|
||||
limit: maxCommonControls,
|
||||
include_entities: favoriteEntities,
|
||||
hide_empty: true,
|
||||
heading: favoritesHeadingCard,
|
||||
} satisfies CommonControlsSectionStrategyConfig,
|
||||
column_span: maxColumns,
|
||||
} satisfies LovelaceStrategySectionConfig,
|
||||
hass
|
||||
);
|
||||
if (!generatedFavoritesSection.disabled) {
|
||||
favoritesSection = {
|
||||
...generatedFavoritesSection,
|
||||
column_span: maxColumns,
|
||||
};
|
||||
}
|
||||
} else if (favoriteEntities.length > 0) {
|
||||
favoritesSection = {
|
||||
type: "grid",
|
||||
@@ -454,6 +468,10 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
}
|
||||
}
|
||||
|
||||
const hasVisibleSummaryCards = summaryCards.some(
|
||||
(card) => !("hide_empty" in card && card.hide_empty)
|
||||
);
|
||||
|
||||
// Build summary cards for sidebar (full width: columns 12)
|
||||
const sidebarSummaryCards = summaryCards.map((card) => ({
|
||||
...card,
|
||||
@@ -500,62 +518,127 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const alertCards = config.alert_entities?.map((alertEntity) =>
|
||||
computeSecurityAlertCardConfig(
|
||||
hass.states[alertEntity.entity],
|
||||
alertEntity
|
||||
)
|
||||
);
|
||||
|
||||
const alertsSection: LovelaceSectionConfig | undefined = alertCards?.length
|
||||
? {
|
||||
type: "grid",
|
||||
column_span: maxColumns,
|
||||
visibility: [
|
||||
{
|
||||
condition: "or",
|
||||
conditions: alertCards.map((alertCard) => ({
|
||||
condition: "and",
|
||||
conditions: alertCard.visibility!,
|
||||
})),
|
||||
},
|
||||
],
|
||||
cards: [
|
||||
{
|
||||
type: "heading",
|
||||
heading: hass.localize(
|
||||
"ui.panel.lovelace.strategy.security.active_alerts"
|
||||
),
|
||||
heading_style: "title",
|
||||
grid_options: { columns: "full" },
|
||||
},
|
||||
...alertCards,
|
||||
],
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const emptyStateCard = {
|
||||
type: "empty-state",
|
||||
icon: "mdi:home-assistant",
|
||||
content_only: true,
|
||||
title: hass.localize("ui.panel.lovelace.strategy.home.welcome_title"),
|
||||
content: hass.localize("ui.panel.lovelace.strategy.home.welcome_content"),
|
||||
...(config.home_panel && hass.user?.is_admin
|
||||
? {
|
||||
buttons: [
|
||||
{
|
||||
icon: "mdi:plus",
|
||||
text: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.welcome_add_device"
|
||||
),
|
||||
appearance: "filled" as const,
|
||||
variant: "brand" as const,
|
||||
tap_action: {
|
||||
action: "fire-dom-event" as const,
|
||||
home_panel: {
|
||||
type: "add_integration",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "mdi:home-edit",
|
||||
text: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.welcome_edit_areas"
|
||||
),
|
||||
appearance: "plain" as const,
|
||||
variant: "brand" as const,
|
||||
tap_action: {
|
||||
action: "navigate" as const,
|
||||
navigation_path: "/config/areas/dashboard",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
} as EmptyStateCardConfig;
|
||||
|
||||
// No sections, show empty state
|
||||
if (floorsSections.length === 0) {
|
||||
if (floorsSections.length === 0 && !alertsSection) {
|
||||
return {
|
||||
type: "panel",
|
||||
cards: [
|
||||
{
|
||||
type: "empty-state",
|
||||
icon: "mdi:home-assistant",
|
||||
content_only: true,
|
||||
title: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.welcome_title"
|
||||
),
|
||||
content: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.welcome_content"
|
||||
),
|
||||
...(config.home_panel && hass.user?.is_admin
|
||||
? {
|
||||
buttons: [
|
||||
{
|
||||
icon: "mdi:plus",
|
||||
text: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.welcome_add_device"
|
||||
),
|
||||
appearance: "filled",
|
||||
variant: "brand",
|
||||
tap_action: {
|
||||
action: "fire-dom-event",
|
||||
home_panel: {
|
||||
type: "add_integration",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "mdi:home-edit",
|
||||
text: hass.localize(
|
||||
"ui.panel.lovelace.strategy.home.welcome_edit_areas"
|
||||
),
|
||||
appearance: "plain",
|
||||
variant: "brand",
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: "/config/areas/dashboard",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
} as EmptyStateCardConfig,
|
||||
],
|
||||
cards: [emptyStateCard],
|
||||
};
|
||||
}
|
||||
|
||||
const emptyStateSection: LovelaceSectionConfig | undefined =
|
||||
floorsSections.length === 0 &&
|
||||
!favoritesSection &&
|
||||
!hasVisibleSummaryCards &&
|
||||
alertCards?.length
|
||||
? {
|
||||
type: "grid",
|
||||
column_span: maxColumns,
|
||||
cards: [
|
||||
{
|
||||
type: "conditional",
|
||||
conditions: [
|
||||
{
|
||||
condition: "not",
|
||||
conditions: [
|
||||
{
|
||||
condition: "or",
|
||||
conditions: alertCards.map((alertCard) => ({
|
||||
condition: "and",
|
||||
conditions: alertCard.visibility!,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
card: emptyStateCard,
|
||||
} satisfies ConditionalCardConfig,
|
||||
],
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const sections = (
|
||||
[favoritesSection, mobileSummarySection, ...floorsSections] satisfies (
|
||||
LovelaceSectionRawConfig | undefined
|
||||
)[]
|
||||
[
|
||||
alertsSection,
|
||||
emptyStateSection,
|
||||
favoritesSection,
|
||||
mobileSummarySection,
|
||||
...floorsSections,
|
||||
] satisfies (LovelaceSectionRawConfig | undefined)[]
|
||||
).filter(Boolean) as LovelaceSectionRawConfig[];
|
||||
|
||||
return {
|
||||
|
||||
@@ -64,6 +64,10 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
|
||||
}
|
||||
|
||||
if (!isComponentLoaded(hass.config, "usage_prediction")) {
|
||||
if (includedEntities.length > 0) {
|
||||
section.cards!.push(...includedEntities.map(toTileCard));
|
||||
return section;
|
||||
}
|
||||
section.cards!.push({
|
||||
type: "markdown",
|
||||
content: hass.localize(
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
HomeFrontendSystemData,
|
||||
SecurityFrontendSystemData,
|
||||
} from "../../../src/data/frontend";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import "../../../src/panels/home/ha-panel-home";
|
||||
import { createMockHass } from "../../fixtures/hass";
|
||||
|
||||
vi.hoisted(() => {
|
||||
Object.assign(globalThis, {
|
||||
__STATIC_PATH__: "/",
|
||||
__HASS_URL__: "",
|
||||
__BUILD__: "modern",
|
||||
__VERSION__: "test",
|
||||
__BACKWARDS_COMPAT__: false,
|
||||
__SUPERVISOR__: false,
|
||||
__NAMESPACE__: "frontend",
|
||||
});
|
||||
});
|
||||
|
||||
type TestPanelHome = HTMLElement & {
|
||||
hass: HomeAssistant;
|
||||
hassSubscribe(): unknown[];
|
||||
_config: HomeFrontendSystemData;
|
||||
_securityConfig: SecurityFrontendSystemData;
|
||||
} & Record<"_loadConfig", () => Promise<void>>;
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
resolve = promiseResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
describe("ha-panel-home configuration loading", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("does not overwrite a newer security subscription update", async () => {
|
||||
const translations = deferred<HomeAssistant["localize"] | undefined>();
|
||||
let securityChanged:
|
||||
| ((data: { value: SecurityFrontendSystemData | null }) => void)
|
||||
| undefined;
|
||||
const hass = createMockHass();
|
||||
hass.loadFragmentTranslation = () => translations.promise;
|
||||
hass.connection = {
|
||||
sendMessagePromise: vi.fn(async (message: { key: string }) => ({
|
||||
value:
|
||||
message.key === "home"
|
||||
? {}
|
||||
: { alert_entities: [{ entity: "binary_sensor.old" }] },
|
||||
})),
|
||||
subscribeMessage: vi.fn(
|
||||
(
|
||||
callback: (data: { value: SecurityFrontendSystemData | null }) => void
|
||||
) => {
|
||||
securityChanged = callback;
|
||||
return Promise.resolve(() => undefined);
|
||||
}
|
||||
),
|
||||
} as unknown as Connection;
|
||||
|
||||
const element = document.createElement(
|
||||
"ha-panel-home"
|
||||
) as unknown as TestPanelHome;
|
||||
element.hass = hass;
|
||||
element.hassSubscribe();
|
||||
const loading = element._loadConfig();
|
||||
|
||||
securityChanged!({
|
||||
value: { alert_entities: [{ entity: "binary_sensor.new" }] },
|
||||
});
|
||||
translations.resolve(undefined);
|
||||
await loading;
|
||||
|
||||
expect(element._securityConfig.alert_entities).toEqual([
|
||||
{ entity: "binary_sensor.new" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves loaded Home config when security loading fails", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const homeConfig: HomeFrontendSystemData = {
|
||||
favorite_entities: ["light.kitchen"],
|
||||
hide_suggested_entities: true,
|
||||
shortcuts: [{ type: "custom", path: "/test", label: "Test" }],
|
||||
};
|
||||
const hass = createMockHass();
|
||||
hass.loadFragmentTranslation = async () => undefined;
|
||||
hass.connection = {
|
||||
sendMessagePromise: vi.fn(async (message: { key: string }) => {
|
||||
if (message.key === "security") {
|
||||
throw new Error("Security unavailable");
|
||||
}
|
||||
return { value: homeConfig };
|
||||
}),
|
||||
} as unknown as Connection;
|
||||
|
||||
const element = document.createElement(
|
||||
"ha-panel-home"
|
||||
) as unknown as TestPanelHome;
|
||||
element.hass = hass;
|
||||
element._securityConfig = {
|
||||
alert_entities: [{ entity: "binary_sensor.old" }],
|
||||
};
|
||||
await element._loadConfig();
|
||||
|
||||
expect(element._config).toEqual(homeConfig);
|
||||
expect(element._securityConfig).toEqual({});
|
||||
});
|
||||
|
||||
it("preserves loaded Home config when translations fail", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const homeConfig: HomeFrontendSystemData = {
|
||||
favorite_entities: ["light.kitchen"],
|
||||
};
|
||||
const hass = createMockHass();
|
||||
hass.loadFragmentTranslation = async () => {
|
||||
throw new Error("Translations unavailable");
|
||||
};
|
||||
hass.connection = {
|
||||
sendMessagePromise: vi.fn(async (message: { key: string }) => ({
|
||||
value: message.key === "home" ? homeConfig : {},
|
||||
})),
|
||||
} as unknown as Connection;
|
||||
|
||||
const element = document.createElement(
|
||||
"ha-panel-home"
|
||||
) as unknown as TestPanelHome;
|
||||
element.hass = hass;
|
||||
await element._loadConfig();
|
||||
|
||||
expect(element._config).toEqual(homeConfig);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { LovelaceSectionConfig } from "../../../../../src/data/lovelace/config/section";
|
||||
import type { LovelaceViewConfig } from "../../../../../src/data/lovelace/config/view";
|
||||
import { checkConditionsMet } from "../../../../../src/panels/lovelace/common/validate-condition";
|
||||
import type { ConditionalCardConfig } from "../../../../../src/panels/lovelace/cards/types";
|
||||
import { HomeOverviewViewStrategy } from "../../../../../src/panels/lovelace/strategies/home/home-overview-view-strategy";
|
||||
import type { HomeAssistant } from "../../../../../src/types";
|
||||
import {
|
||||
createMockEntityState,
|
||||
createMockHass,
|
||||
} from "../../../../fixtures/hass";
|
||||
|
||||
const createHass = (state: string): HomeAssistant => {
|
||||
const hass = createMockHass({
|
||||
"binary_sensor.front_door": createMockEntityState(
|
||||
"binary_sensor.front_door",
|
||||
state,
|
||||
{ device_class: "door" }
|
||||
),
|
||||
});
|
||||
return {
|
||||
...hass,
|
||||
config: { ...hass.config, components: [] },
|
||||
panels: {},
|
||||
};
|
||||
};
|
||||
|
||||
const sections = (view: LovelaceViewConfig): LovelaceSectionConfig[] => {
|
||||
expect(view.type).toBe("sections");
|
||||
return view.sections as LovelaceSectionConfig[];
|
||||
};
|
||||
|
||||
describe("HomeOverviewViewStrategy security alerts", () => {
|
||||
it("maps configured severity to the security alert card config", async () => {
|
||||
const view = await HomeOverviewViewStrategy.generate(
|
||||
{
|
||||
type: "home-overview",
|
||||
alert_entities: [
|
||||
{ entity: "binary_sensor.front_door", severity: "alert" },
|
||||
],
|
||||
},
|
||||
createHass("on")
|
||||
);
|
||||
const alertSection = sections(view)[0];
|
||||
|
||||
expect(alertSection.cards).toEqual([
|
||||
{
|
||||
type: "heading",
|
||||
heading: "ui.panel.lovelace.strategy.security.active_alerts",
|
||||
heading_style: "title",
|
||||
grid_options: { columns: "full" },
|
||||
},
|
||||
{
|
||||
type: "alert",
|
||||
entity: "binary_sensor.front_door",
|
||||
color: "red",
|
||||
visibility: [
|
||||
{
|
||||
condition: "state",
|
||||
entity: "binary_sensor.front_door",
|
||||
state: "on",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reactively shows the area-less empty state only without active alerts", async () => {
|
||||
const inactiveHass = createHass("off");
|
||||
const view = await HomeOverviewViewStrategy.generate(
|
||||
{
|
||||
type: "home-overview",
|
||||
alert_entities: [{ entity: "binary_sensor.front_door" }],
|
||||
hide_suggested_entities: true,
|
||||
},
|
||||
inactiveHass
|
||||
);
|
||||
const emptyStateCard = sections(view)[1]
|
||||
?.cards?.[0] as ConditionalCardConfig;
|
||||
|
||||
expect(
|
||||
checkConditionsMet(emptyStateCard.conditions, inactiveHass, {})
|
||||
).toBe(true);
|
||||
expect(
|
||||
checkConditionsMet(emptyStateCard.conditions, createHass("on"), {})
|
||||
).toBe(false);
|
||||
expect(emptyStateCard.card.type).toBe("empty-state");
|
||||
});
|
||||
|
||||
it("shows the area-less empty state when prediction returns no controls", async () => {
|
||||
const hass = createHass("off");
|
||||
hass.config = { ...hass.config, components: ["usage_prediction"] };
|
||||
hass.callWS = vi.fn().mockResolvedValue({ entities: [] });
|
||||
const view = await HomeOverviewViewStrategy.generate(
|
||||
{
|
||||
type: "home-overview",
|
||||
alert_entities: [{ entity: "binary_sensor.front_door" }],
|
||||
},
|
||||
hass
|
||||
);
|
||||
|
||||
expect(
|
||||
sections(view).some((section) =>
|
||||
section.cards?.some((card) => card.type === "conditional")
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not show the area-less empty state with a predicted control", async () => {
|
||||
const hass = createHass("off");
|
||||
hass.states["light.kitchen"] = createMockEntityState("light.kitchen", "on");
|
||||
hass.config = { ...hass.config, components: ["usage_prediction"] };
|
||||
hass.callWS = vi.fn().mockResolvedValue({ entities: ["light.kitchen"] });
|
||||
const view = await HomeOverviewViewStrategy.generate(
|
||||
{
|
||||
type: "home-overview",
|
||||
alert_entities: [{ entity: "binary_sensor.front_door" }],
|
||||
},
|
||||
hass
|
||||
);
|
||||
|
||||
expect(
|
||||
sections(view).some((section) =>
|
||||
section.cards?.some((card) => card.type === "conditional")
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the Home view when prediction loading fails", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const hass = createHass("off");
|
||||
hass.config = { ...hass.config, components: ["usage_prediction"] };
|
||||
hass.callWS = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("Prediction unavailable"));
|
||||
const view = await HomeOverviewViewStrategy.generate(
|
||||
{
|
||||
type: "home-overview",
|
||||
alert_entities: [{ entity: "binary_sensor.front_door" }],
|
||||
},
|
||||
hass
|
||||
);
|
||||
|
||||
const alertCard = sections(view)[0]?.cards?.[1];
|
||||
expect(alertCard?.type).toBe("alert");
|
||||
});
|
||||
|
||||
it("does not show the area-less empty state with configured favorites", async () => {
|
||||
const view = await HomeOverviewViewStrategy.generate(
|
||||
{
|
||||
type: "home-overview",
|
||||
alert_entities: [{ entity: "binary_sensor.front_door" }],
|
||||
favorite_entities: ["binary_sensor.front_door"],
|
||||
},
|
||||
createHass("off")
|
||||
);
|
||||
|
||||
expect(
|
||||
sections(view).some((section) =>
|
||||
section.cards?.some((card) => card.type === "conditional")
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not show the area-less empty state with a visible shortcut", async () => {
|
||||
const view = await HomeOverviewViewStrategy.generate(
|
||||
{
|
||||
type: "home-overview",
|
||||
alert_entities: [{ entity: "binary_sensor.front_door" }],
|
||||
hide_suggested_entities: true,
|
||||
shortcuts: [{ type: "custom", path: "/test", label: "Test" }],
|
||||
},
|
||||
createHass("off")
|
||||
);
|
||||
|
||||
expect(
|
||||
sections(view).some((section) =>
|
||||
section.cards?.some((card) => card.type === "conditional")
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user