From 0a05dd8f71e077907cb9fd1e3e7864a9eb4656d5 Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Thu, 20 Feb 2025 20:11:53 +0100 Subject: [PATCH] Add more tests for common/entity (#24336) * Use substring instead of deprecated substr * Add more common entity tests --- package.json | 1 + src/common/entity/compute_domain.ts | 2 +- src/common/entity/compute_state_display.ts | 6 +- test/common/entity/can_toggle_state.test.ts | 24 ++ .../entity/compute_attribute_display.test.ts | 371 ++++++++++++++++++ .../entity/compute_state_display.test.ts | 91 ++++- test/vitest.config.ts | 2 + tsconfig.json | 32 +- yarn.lock | 38 ++ 9 files changed, 558 insertions(+), 9 deletions(-) create mode 100644 test/common/entity/compute_attribute_display.test.ts diff --git a/package.json b/package.json index ef3b21249b..8ce0aff918 100644 --- a/package.json +++ b/package.json @@ -226,6 +226,7 @@ "ts-lit-plugin": "2.0.2", "typescript": "5.7.3", "typescript-eslint": "8.24.1", + "vite-tsconfig-paths": "5.1.4", "vitest": "3.0.5", "webpack-stats-plugin": "1.1.3", "webpackbar": "7.0.0", diff --git a/src/common/entity/compute_domain.ts b/src/common/entity/compute_domain.ts index 29e5688859..005bdb25b3 100644 --- a/src/common/entity/compute_domain.ts +++ b/src/common/entity/compute_domain.ts @@ -1,2 +1,2 @@ export const computeDomain = (entityId: string): string => - entityId.substr(0, entityId.indexOf(".")); + entityId.substring(0, entityId.indexOf(".")); diff --git a/src/common/entity/compute_state_display.ts b/src/common/entity/compute_state_display.ts index 49c9d5182a..bd61083c82 100644 --- a/src/common/entity/compute_state_display.ts +++ b/src/common/entity/compute_state_display.ts @@ -120,11 +120,6 @@ export const computeStateDisplayFromEntityAttributes = ( return value; } - if (domain === "datetime") { - const time = new Date(state); - return formatDateTime(time, locale, config); - } - if (["date", "input_datetime", "time"].includes(domain)) { // If trying to display an explicit state, need to parse the explicit state to `Date` then format. // Attributes aren't available, we have to use `state`. @@ -181,6 +176,7 @@ export const computeStateDisplayFromEntityAttributes = ( "tag", "tts", "wake_word", + "datetime", ].includes(domain) || (domain === "sensor" && attributes.device_class === "timestamp") ) { diff --git a/test/common/entity/can_toggle_state.test.ts b/test/common/entity/can_toggle_state.test.ts index 42a71d1944..c85ea7887b 100644 --- a/test/common/entity/can_toggle_state.test.ts +++ b/test/common/entity/can_toggle_state.test.ts @@ -63,4 +63,28 @@ describe("canToggleState", () => { }; assert.isFalse(canToggleState(hass, stateObj)); }); + + it("Detects group with missing entity", () => { + const stateObj: any = { + entity_id: "group.bla", + state: "on", + attributes: { + entity_id: ["light.non_existing"], + }, + }; + + assert.isFalse(canToggleState(hass, stateObj)); + }); + + it("Detects group with off state", () => { + const stateObj: any = { + entity_id: "group.bla", + state: "off", + attributes: { + entity_id: ["light.test"], + }, + }; + + assert.isTrue(canToggleState(hass, stateObj)); + }); }); diff --git a/test/common/entity/compute_attribute_display.test.ts b/test/common/entity/compute_attribute_display.test.ts new file mode 100644 index 0000000000..ad606e9d01 --- /dev/null +++ b/test/common/entity/compute_attribute_display.test.ts @@ -0,0 +1,371 @@ +import type { + HassConfig, + HassEntity, + HassEntityBase, +} from "home-assistant-js-websocket"; +import { describe, it, expect } from "vitest"; +import { + computeAttributeValueDisplay, + computeAttributeNameDisplay, +} from "../../../src/common/entity/compute_attribute_display"; +import type { FrontendLocaleData } from "../../../src/data/translation"; +import type { HomeAssistant } from "../../../src/types"; + +export const localizeMock = (key: string) => { + const translations = { + "state.default.unknown": "Unknown", + "component.test_platform.entity.sensor.test_translation_key.state_attributes.attribute.state.42": + "42", + "component.test_platform.entity.sensor.test_translation_key.state_attributes.attribute.state.attributeValue": + "Localized Attribute Name", + "component.media_player.entity_component.media_player.state_attributes.attribute.state.attributeValue": + "Localized Media Player Attribute Name", + "component.media_player.entity_component._.state_attributes.attribute.state.attributeValue": + "Media Player Attribute Name", + }; + return translations[key] || ""; +}; + +export const stateObjMock = { + entity_id: "sensor.test", + attributes: { + device_class: "temperature", + }, +} as HassEntityBase; + +export const localeMock = { + language: "en", +} as FrontendLocaleData; + +export const configMock = { + unit_system: { + temperature: "°C", + }, +} as HassConfig; + +export const entitiesMock = { + "sensor.test": { + platform: "test_platform", + translation_key: "test_translation_key", + }, + "media_player.test": { + platform: "media_player", + }, +} as unknown as HomeAssistant["entities"]; + +describe("computeAttributeValueDisplay", () => { + it("should return unknown state for null value", () => { + const result = computeAttributeValueDisplay( + localizeMock, + stateObjMock, + localeMock, + configMock, + entitiesMock, + "attribute", + null + ); + expect(result).toBe("Unknown"); + }); + + it("should return formatted number for numeric value", () => { + const result = computeAttributeValueDisplay( + localizeMock, + stateObjMock, + localeMock, + configMock, + entitiesMock, + "attribute", + 42 + ); + expect(result).toBe("42"); + }); + + it("should return number from formatter", () => { + const stateObj = { + entity_id: "media_player.test", + attributes: { + device_class: "media_player", + volume_level: 0.42, + }, + } as unknown as HassEntityBase; + const result = computeAttributeValueDisplay( + localizeMock, + stateObj, + localeMock, + configMock, + entitiesMock, + "volume_level" + ); + expect(result).toBe("42%"); + }); + + it("should return formatted date for date string", () => { + const result = computeAttributeValueDisplay( + localizeMock, + stateObjMock, + localeMock, + configMock, + entitiesMock, + "attribute", + "2023-10-10" + ); + expect(result).toBe("October 10, 2023"); + }); + + it("should return formatted datetime for timestamp", () => { + const result = computeAttributeValueDisplay( + localizeMock, + stateObjMock, + localeMock, + configMock, + entitiesMock, + "attribute", + "2023-10-10T10:10:10" + ); + expect(result).toBe("October 10, 2023 at 10:10:10"); + }); + + it("should return JSON string for object value", () => { + const result = computeAttributeValueDisplay( + localizeMock, + stateObjMock, + localeMock, + configMock, + entitiesMock, + "attribute", + { key: "value" } + ); + expect(result).toBe('{"key":"value"}'); + }); + + it("should return concatenated values for array", () => { + const result = computeAttributeValueDisplay( + localizeMock, + stateObjMock, + localeMock, + configMock, + entitiesMock, + "attribute", + [1, 2, 3] + ); + expect(result).toBe("1, 2, 3"); + }); + + it("should set special unit for weather domain", () => { + const stateObj = { + entity_id: "weather.test", + attributes: { + temperature: 42, + }, + } as unknown as HassEntityBase; + const result = computeAttributeValueDisplay( + localizeMock, + stateObj, + localeMock, + configMock, + entitiesMock, + "temperature" + ); + expect(result).toBe("42 °C"); + }); + + it("should set temperature unit for temperature attribute", () => { + const stateObj = { + entity_id: "sensor.test", + attributes: { + temperature: 42, + }, + } as unknown as HassEntityBase; + const result = computeAttributeValueDisplay( + localizeMock, + stateObj, + localeMock, + configMock, + entitiesMock, + "temperature" + ); + expect(result).toBe("42 °C"); + }); + + it("should return translation from translation key", () => { + const result = computeAttributeValueDisplay( + localizeMock, + stateObjMock, + localeMock, + configMock, + entitiesMock, + "attribute", + "attributeValue" + ); + expect(result).toBe("Localized Attribute Name"); + }); + + it("should return device class translation", () => { + const stateObj = { + entity_id: "media_player.test", + attributes: { + device_class: "media_player", + volume_level: 0.42, + }, + } as unknown as HassEntityBase; + const result = computeAttributeValueDisplay( + localizeMock, + stateObj, + localeMock, + configMock, + entitiesMock, + "attribute", + "attributeValue" + ); + expect(result).toBe("Localized Media Player Attribute Name"); + }); + + it("should return attribute value translation", () => { + const stateObj = { + entity_id: "media_player.test", + attributes: { + volume_level: 0.42, + }, + } as unknown as HassEntityBase; + const result = computeAttributeValueDisplay( + localizeMock, + stateObj, + localeMock, + configMock, + entitiesMock, + "attribute", + "attributeValue" + ); + expect(result).toBe("Media Player Attribute Name"); + }); + + it("should return attribute value", () => { + const stateObj = { + entity_id: "media_player.test", + attributes: { + volume_level: 0.42, + }, + } as unknown as HassEntityBase; + const result = computeAttributeValueDisplay( + localizeMock, + stateObj, + localeMock, + configMock, + entitiesMock, + "attribute", + "attributeValue2" + ); + expect(result).toBe("attributeValue2"); + }); +}); + +describe("computeAttributeNameDisplay", () => { + it("should return localized name for attribute", () => { + const localize = (key: string) => { + if ( + key === + "component.light.entity.light.entity_translation_key.state_attributes.updated_at.name" + ) { + return "Updated at"; + } + return "unknown"; + }; + + const stateObj = { + entity_id: "light.test", + attributes: { + device_class: "light", + }, + } as HassEntity; + + const entities = { + "light.test": { + translation_key: "entity_translation_key", + platform: "light", + }, + } as unknown as HomeAssistant["entities"]; + + const result = computeAttributeNameDisplay( + localize, + stateObj, + entities, + "updated_at" + ); + expect(result).toBe("Updated at"); + }); + + it("should return device class translation", () => { + const localize = (key: string) => { + if ( + key === + "component.light.entity_component.light.state_attributes.brightness.name" + ) { + return "Brightness"; + } + return "unknown"; + }; + + const stateObj = { + entity_id: "light.test", + attributes: { + device_class: "light", + }, + } as HassEntity; + + const entities = {} as unknown as HomeAssistant["entities"]; + + const result = computeAttributeNameDisplay( + localize, + stateObj, + entities, + "brightness" + ); + expect(result).toBe("Brightness"); + }); + + it("should return default attribute name", () => { + const localize = (key: string) => { + if ( + key === + "component.light.entity_component._.state_attributes.brightness.name" + ) { + return "Brightness"; + } + return "unknown"; + }; + + const stateObj = { + entity_id: "light.test", + attributes: {}, + } as HassEntity; + + const entities = {} as unknown as HomeAssistant["entities"]; + + const result = computeAttributeNameDisplay( + localize, + stateObj, + entities, + "brightness" + ); + expect(result).toBe("Brightness"); + }); + + it("should return capitalized attribute name", () => { + const localize = () => ""; + + const stateObj = { + entity_id: "light.test", + attributes: {}, + } as HassEntity; + + const entities = {} as unknown as HomeAssistant["entities"]; + + const result = computeAttributeNameDisplay( + localize, + stateObj, + entities, + "brightness__ip_id_mac_gps_GPS" + ); + expect(result).toBe("Brightness IP ID MAC GPS GPS"); + }); +}); diff --git a/test/common/entity/compute_state_display.test.ts b/test/common/entity/compute_state_display.test.ts index 2a120abe5c..05934c5ba2 100644 --- a/test/common/entity/compute_state_display.test.ts +++ b/test/common/entity/compute_state_display.test.ts @@ -1,5 +1,9 @@ -import { assert, describe, it, beforeEach } from "vitest"; -import { computeStateDisplay } from "../../../src/common/entity/compute_state_display"; +import type { HassConfig } from "home-assistant-js-websocket"; +import { assert, describe, it, beforeEach, expect } from "vitest"; +import { + computeStateDisplay, + computeStateDisplayFromEntityAttributes, +} from "../../../src/common/entity/compute_state_display"; import { UNKNOWN } from "../../../src/data/entity"; import type { FrontendLocaleData } from "../../../src/data/translation"; import { @@ -10,6 +14,7 @@ import { TimeZone, } from "../../../src/data/translation"; import { demoConfig } from "../../../src/fake_data/demo_config"; +import type { EntityRegistryDisplayEntry } from "../../../src/data/entity_registry"; let localeData: FrontendLocaleData; @@ -617,3 +622,85 @@ describe("computeStateDisplay", () => { ); }); }); + +describe("computeStateDisplayFromEntityAttributes with numeric device classes", () => { + it("Should format duration sensor", () => { + const result = computeStateDisplayFromEntityAttributes( + // eslint-disable-next-line @typescript-eslint/no-empty-function + (() => {}) as any, + { + language: "en", + } as FrontendLocaleData, + [], + {} as HassConfig, + { + display_precision: 2, + } as EntityRegistryDisplayEntry, + "number.test", + { + device_class: "duration", + unit_of_measurement: "min", + }, + "12" + ); + expect(result).toBe("12.00 min"); + }); + it("Should format duration sensor with seconds", () => { + const result = computeStateDisplayFromEntityAttributes( + // eslint-disable-next-line @typescript-eslint/no-empty-function + (() => {}) as any, + { + language: "en", + } as FrontendLocaleData, + [], + {} as HassConfig, + undefined, + "number.test", + { + device_class: "duration", + unit_of_measurement: "s", + }, + "12" + ); + expect(result).toBe("12 s"); + }); + + it("Should format monetary device_class", () => { + const result = computeStateDisplayFromEntityAttributes( + // eslint-disable-next-line @typescript-eslint/no-empty-function + (() => {}) as any, + { + language: "en", + } as FrontendLocaleData, + [], + {} as HassConfig, + undefined, + "number.test", + { + device_class: "monetary", + unit_of_measurement: "$", + }, + "12" + ); + expect(result).toBe("12 $"); + }); +}); + +describe("computeStateDisplayFromEntityAttributes datetime device calss", () => { + it("Should format datetime sensor", () => { + const result = computeStateDisplayFromEntityAttributes( + // eslint-disable-next-line @typescript-eslint/no-empty-function + (() => {}) as any, + { + language: "en", + } as FrontendLocaleData, + [], + {} as HassConfig, + undefined, + "button.test", + {}, + "2020-01-01T12:00:00+00:00" + ); + expect(result).toBe("January 1, 2020 at 12:00"); + }); +}); diff --git a/test/vitest.config.ts b/test/vitest.config.ts index 811a8a8a7d..6c2fff36f7 100644 --- a/test/vitest.config.ts +++ b/test/vitest.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ + plugins: [tsconfigPaths()], test: { environment: "jsdom", // to run in browser-like environment env: { diff --git a/tsconfig.json b/tsconfig.json index 83c5f840ac..7c24087739 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -49,6 +49,36 @@ "./node_modules/@lrnwebcomponents/simple-tooltip/custom-elements.json" ] } - ] + ], + "paths": { + "lit/static-html": ["./node_modules/lit/static-html.js"], + "lit/decorators": ["./node_modules/lit/decorators.js"], + "lit/directive": ["./node_modules/lit/directive.js"], + "lit/directives/until": ["./node_modules/lit/directives/until.js"], + "lit/directives/class-map": [ + "./node_modules/lit/directives/class-map.js" + ], + "lit/directives/style-map": [ + "./node_modules/lit/directives/style-map.js" + ], + "lit/directives/if-defined": [ + "./node_modules/lit/directives/if-defined.js" + ], + "lit/directives/guard": ["./node_modules/lit/directives/guard.js"], + "lit/directives/cache": ["./node_modules/lit/directives/cache.js"], + "lit/directives/repeat": ["./node_modules/lit/directives/repeat.js"], + "lit/directives/live": ["./node_modules/lit/directives/live.js"], + "lit/directives/keyed": ["./node_modules/lit/directives/keyed.js"], + "lit/polyfill-support": ["./node_modules/lit/polyfill-support.js"], + "@lit-labs/virtualizer/layouts/grid": [ + "./node_modules/@lit-labs/virtualizer/layouts/grid.js" + ], + "@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver": [ + "./node_modules/@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver.js" + ], + "@lit-labs/observers/resize-controller": [ + "./node_modules/@lit-labs/observers/resize-controller.js" + ] + } } } diff --git a/yarn.lock b/yarn.lock index 30493959cf..4e058b596d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9067,6 +9067,13 @@ __metadata: languageName: node linkType: hard +"globrex@npm:^0.1.2": + version: 0.1.2 + resolution: "globrex@npm:0.1.2" + checksum: 10/81ce62ee6f800d823d6b7da7687f841676d60ee8f51f934ddd862e4057316d26665c4edc0358d4340a923ac00a514f8b67c787e28fe693aae16350f4e60d55e9 + languageName: node + linkType: hard + "glogg@npm:^2.2.0": version: 2.2.0 resolution: "glogg@npm:2.2.0" @@ -9489,6 +9496,7 @@ __metadata: ua-parser-js: "npm:2.0.2" vis-data: "npm:7.1.9" vis-network: "npm:9.1.9" + vite-tsconfig-paths: "npm:5.1.4" vitest: "npm:3.0.5" vue: "npm:2.7.16" vue2-daterange-picker: "npm:0.6.8" @@ -14462,6 +14470,20 @@ __metadata: languageName: node linkType: hard +"tsconfck@npm:^3.0.3": + version: 3.1.5 + resolution: "tsconfck@npm:3.1.5" + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + bin: + tsconfck: bin/tsconfck.js + checksum: 10/46b68f0fcec7da045490e427400c2a7fea67bdb6dae871257e8d2ec48e9dc99674214df86ff51c6d01ceb68ba9d7d806d3b69de432efa3c76b5fba160c252857 + languageName: node + linkType: hard + "tsconfig-paths@npm:^3.15.0": version: 3.15.0 resolution: "tsconfig-paths@npm:3.15.0" @@ -15110,6 +15132,22 @@ __metadata: languageName: node linkType: hard +"vite-tsconfig-paths@npm:5.1.4": + version: 5.1.4 + resolution: "vite-tsconfig-paths@npm:5.1.4" + dependencies: + debug: "npm:^4.1.1" + globrex: "npm:^0.1.2" + tsconfck: "npm:^3.0.3" + peerDependencies: + vite: "*" + peerDependenciesMeta: + vite: + optional: true + checksum: 10/b409dbd17829f560021a71dba3e473b9c06dcf5fdc9d630b72c1f787145ec478b38caff1be04868971ac8bdcbf0f5af45eeece23dbc9c59c54b901f867740ae0 + languageName: node + linkType: hard + "vite@npm:^5.0.0 || ^6.0.0": version: 6.0.11 resolution: "vite@npm:6.0.11"