Compare commits

..
Author SHA1 Message Date
Petar Petrov 02e9ef9c05 Drop hass from the entity state pickers
getStates takes the states and language, so the state pickers read entity states, formatters and translations from contexts.
2026-10-26 18:39:39 +02:00
9 changed files with 201 additions and 291 deletions

No files matched your search

+12 -13
View File
@@ -1,6 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { stringCompare } from "../string/compare";
import { computeDomain } from "./compute_domain";
import { computeStateDomain } from "./compute_state_domain";
@@ -248,7 +247,8 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
};
export const getStatesDomain = (
hass: HomeAssistant,
states: HassEntities,
language: string,
domain: string,
attribute?: string | undefined
): string[] => {
@@ -274,15 +274,13 @@ export const getStatesDomain = (
case "person":
if (!attribute) {
result.push(
...Object.entries(hass.states)
...Object.entries(states)
.filter(
([entityId]) =>
computeDomain(entityId) === "zone" && entityId !== "zone.home"
)
.map(([_entityId, stateObj]) => computeZoneTrackerState(stateObj))
.sort((zone1, zone2) =>
stringCompare(zone1, zone2, hass.locale.language)
)
.sort((zone1, zone2) => stringCompare(zone1, zone2, language))
);
}
break;
@@ -370,23 +368,24 @@ export const getValueAttribute = (
): string | undefined => DOMAIN_VALUE_ATTRIBUTES[domain]?.[optionsAttribute];
export const getStates = (
hass: HomeAssistant,
state: HassEntity,
states: HassEntities,
language: string,
stateObj: HassEntity,
attribute: string | undefined = undefined
): string[] => {
const domain = computeStateDomain(state);
const domain = computeStateDomain(stateObj);
const result: string[] = [];
// Fixed values based on a domain
result.push(...getStatesDomain(hass, domain, attribute));
result.push(...getStatesDomain(states, language, domain, attribute));
// Dynamic values based on the entities
const optionsAttribute = getOptionsAttribute(domain, attribute);
if (optionsAttribute) {
const options = state.attributes[optionsAttribute];
const options = stateObj.attributes[optionsAttribute];
// Sensors only expose their options when their device class is `enum`.
const enumSensor =
domain !== "sensor" || state.attributes.device_class === "enum";
domain !== "sensor" || stateObj.attributes.device_class === "enum";
if (enumSensor && Array.isArray(options)) {
result.push(...options);
}
+57 -26
View File
@@ -1,18 +1,25 @@
import { LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { ContextType } from "@lit/context";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { consume } from "../../common/decorators/consume";
import { consumeEntityStates } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import { getStates } from "../../common/entity/get_states";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import {
formattersContext,
internationalizationContext,
statesContext,
} from "../../data/context";
import type { HomeAssistantFormatters, ValueChangedEvent } from "../../types";
import "../ha-generic-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
@customElement("ha-entity-state-picker")
export class HaEntityStatePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId?: string | string[];
@property() public attribute?: string;
@@ -38,9 +45,26 @@ export class HaEntityStatePicker extends LitElement {
@property() public helper?: string;
@state()
@consumeEntityStates({ entityIdPath: ["entityId"] })
private _stateObjs?: Record<string, HassEntity>;
@consume({ context: statesContext, subscribe: true })
private _states?: ContextType<typeof statesContext>;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: ContextType<typeof formattersContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
private _getItems = memoizeOne(
(
hass: HomeAssistant,
states: HassEntities | null | undefined,
formatters: HomeAssistantFormatters,
language: string,
entityId: string | string[] | undefined,
attribute: string | undefined,
hideStates: string[] | undefined,
@@ -50,20 +74,22 @@ export class HaEntityStatePicker extends LitElement {
const entitiesOptions = entityIds.map<PickerComboBoxItem[]>(
(entityIdItem) => {
const stateObj = hass.states[entityIdItem] || {
entity_id: entityIdItem,
attributes: {},
};
const stateObj =
states?.[entityIdItem] ||
({ entity_id: entityIdItem, attributes: {} } as HassEntity);
const states = getStates(hass, stateObj, attribute).filter(
(s) => !hideStates?.includes(s)
);
const options = getStates(
states ?? {},
language,
stateObj,
attribute
).filter((s) => !hideStates?.includes(s));
return states
return options
.map((s) => {
const primary = attribute
? hass.formatEntityAttributeValue(stateObj, attribute, s)
: hass.formatEntityState(stateObj, s);
? formatters.formatEntityAttributeValue(stateObj, attribute, s)
: formatters.formatEntityState(stateObj, s);
return {
id: s,
primary,
@@ -102,7 +128,9 @@ export class HaEntityStatePicker extends LitElement {
_section?: string
): PickerComboBoxItem[] =>
this._getItems(
this.hass,
this._states,
this._formatters,
this._i18n.locale.language,
this.entityId,
this.attribute,
this.hideStates,
@@ -121,19 +149,20 @@ export class HaEntityStatePicker extends LitElement {
// entity to resolve the translated name; otherwise fall back to "State".
if (this.attribute && this.entityId) {
const entityId = ensureArray(this.entityId)[0];
const stateObj = entityId ? this.hass.states[entityId] : undefined;
const stateObj = entityId ? this._stateObjs?.[entityId] : undefined;
if (stateObj) {
return this.hass.formatEntityAttributeName(stateObj, this.attribute);
return this._formatters.formatEntityAttributeName(
stateObj,
this.attribute
);
}
}
return this.hass.localize("ui.components.entity.entity-state-picker.state");
return this._i18n.localize(
"ui.components.entity.entity-state-picker.state"
);
}
protected render() {
if (!this.hass) {
return nothing;
}
const noEntity = !ensureArray(this.entityId)?.length;
return html`
@@ -147,8 +176,10 @@ export class HaEntityStatePicker extends LitElement {
.value=${this.value}
.getItems=${this._getFilteredItems}
.valueRenderer=${this._valueRenderer}
.notFoundLabel=${this.hass.localize("ui.components.combo-box.no_match")}
.customValueLabel=${this.hass.localize(
.notFoundLabel=${this._i18n.localize(
"ui.components.combo-box.no_match"
)}
.customValueLabel=${this._i18n.localize(
"ui.components.entity.entity-state-picker.add_custom_state"
)}
@value-changed=${this._valueChanged}
@@ -6,13 +6,10 @@ import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import { ANY_STATE_VALUE } from "./const";
import { ensureArray } from "../../common/array/ensure-array";
import type { HomeAssistant } from "../../types";
import "./ha-entity-state-picker";
@customElement("ha-entity-states-picker")
export class HaEntityStatesPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId?: string;
@property() public attribute?: string;
@@ -52,10 +49,6 @@ export class HaEntityStatesPicker extends LitElement {
}
protected render() {
if (!this.hass) {
return nothing;
}
const value = this.value || [];
const hide = [...(this.hideStates || []), ...value];
const hideValue = value.includes(ANY_STATE_VALUE);
@@ -68,7 +61,6 @@ export class HaEntityStatesPicker extends LitElement {
<div>
<ha-entity-state-picker
.index=${index}
.hass=${this.hass}
.entityId=${this.entityId}
.attribute=${this.attribute}
.extraOptions=${this.extraOptions}
@@ -94,7 +86,6 @@ export class HaEntityStatesPicker extends LitElement {
: keyed(
value.length,
html`<ha-entity-state-picker
.hass=${this.hass}
.entityId=${this.entityId}
.attribute=${this.attribute}
.extraOptions=${this.extraOptions}
@@ -5,31 +5,15 @@ import {
mdiPencil,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { HassEntities } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { consume } from "../../common/decorators/consume";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import {
formattersContext,
internationalizationContext,
registriesContext,
statesContext,
} from "../../data/context";
import type { ObjectSelector } from "../../data/selector";
import {
formatSelectorValue,
type FormatSelectorValueOptions,
} from "../../data/selector/format_selector_value";
import { formatSelectorValue } from "../../data/selector/format_selector_value";
import { showFormDialog } from "../../dialogs/form/show-form-dialog";
import type {
HomeAssistantFormatters,
HomeAssistantInternationalization,
HomeAssistantRegistries,
} from "../../types";
import type { HomeAssistant } from "../../types";
import { computeInitialHaFormData } from "../ha-form/compute-initial-ha-form-data";
import type { HaFormSchema } from "../ha-form/types";
import "../ha-input-helper-text";
@@ -42,6 +26,8 @@ import { deepEqual } from "../../common/util/deep-equal";
@customElement("ha-selector-object")
export class HaObjectSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: ObjectSelector;
@property() public value?: any;
@@ -61,28 +47,6 @@ export class HaObjectSelector extends LitElement {
values?: Record<string, string>
) => string;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: HomeAssistantRegistries;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: HassEntities;
@state()
@consume({ context: formattersContext, subscribe: true })
@transform<
HomeAssistantFormatters,
HomeAssistantFormatters["formatEntityName"]
>({
transformer: (formatters) => formatters?.formatEntityName,
})
private _formatEntityName!: HomeAssistantFormatters["formatEntityName"];
@query("ha-yaml-editor", true) private _yamlEditor?: HaYamlEditor;
private _valueChangedFromChild = false;
@@ -119,12 +83,7 @@ export class HaObjectSelector extends LitElement {
private _formatField(item: any, field: string): string {
const fields = this.selector.object!.fields!;
const value = formatSelectorValue(
this._formatOptions(
this._i18n,
this._registries,
this._states,
this._formatEntityName
),
this.hass,
item[field],
fields[field]?.selector
);
@@ -189,14 +148,14 @@ export class HaObjectSelector extends LitElement {
slot="end"
.item=${item}
.index=${index}
.label=${this._i18n.localize("ui.common.edit")}
.label=${this.hass.localize("ui.common.edit")}
.path=${mdiPencil}
@click=${this._editItem}
></ha-icon-button>
<ha-icon-button
slot="end"
.index=${index}
.label=${this._i18n.localize("ui.common.delete")}
.label=${this.hass.localize("ui.common.delete")}
.path=${multiple ? mdiDelete : mdiClose}
@click=${this._deleteItem}
></ha-icon-button>
@@ -221,7 +180,7 @@ export class HaObjectSelector extends LitElement {
</ha-list-base>
</ha-sortable>
<ha-button appearance="filled" @click=${this._addItem}>
${this._i18n.localize("ui.common.add")}
${this.hass.localize("ui.common.add")}
</ha-button>
</div>
`;
@@ -237,7 +196,7 @@ export class HaObjectSelector extends LitElement {
</ha-list-base>`
: html`
<ha-button appearance="filled" @click=${this._addItem}>
${this._i18n.localize("ui.common.add")}
${this.hass.localize("ui.common.add")}
</ha-button>
`
}
@@ -260,23 +219,6 @@ export class HaObjectSelector extends LitElement {
} `;
}
private _formatOptions = memoizeOne(
(
i18n: HomeAssistantInternationalization,
registries: HomeAssistantRegistries,
states: HassEntities,
formatEntityName: HomeAssistantFormatters["formatEntityName"]
): FormatSelectorValueOptions => ({
locale: i18n.locale,
localize: i18n.localize,
states,
devices: registries.devices,
areas: registries.areas,
floors: registries.floors,
formatEntityName,
})
);
private _schema = memoizeOne((selector: ObjectSelector) => {
if (!selector.object || !selector.object.fields) {
return [];
@@ -320,12 +262,12 @@ export class HaObjectSelector extends LitElement {
};
const newItem = await showFormDialog(this, {
title: this._i18n.localize("ui.common.add"),
title: this.hass.localize("ui.common.add"),
schema,
data,
computeLabel: this._computeLabel,
computeHelper: this._computeHelper,
submitText: this._i18n.localize("ui.common.add"),
submitText: this.hass.localize("ui.common.add"),
});
if (newItem === null) {
@@ -348,12 +290,12 @@ export class HaObjectSelector extends LitElement {
const index = ev.currentTarget.index;
const updatedItem = await showFormDialog(this, {
title: this._i18n.localize("ui.common.edit"),
title: this.hass.localize("ui.common.edit"),
schema: this._schema(this.selector),
data: item,
computeLabel: this._computeLabel,
computeHelper: this._computeHelper,
submitText: this._i18n.localize("ui.common.save"),
submitText: this.hass.localize("ui.common.save"),
});
if (updatedItem === null) {
@@ -74,7 +74,6 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
if (this.selector.state?.multiple) {
return html`
<ha-entity-states-picker
.hass=${this.hass}
.entityId=${this._entityIds}
.attribute=${
this.selector.state?.attribute || this.context?.filter_attribute
@@ -92,7 +91,6 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
}
return html`
<ha-entity-state-picker
.hass=${this.hass}
.entityId=${this._entityIds}
.attribute=${
this.selector.state?.attribute || this.context?.filter_attribute
+11 -27
View File
@@ -1,4 +1,3 @@
import type { HassEntities } from "home-assistant-js-websocket";
import { ensureArray } from "../../common/array/ensure-array";
import { createDurationData } from "../../common/datetime/create_duration_data";
import { formatDurationLong } from "../../common/datetime/format_duration";
@@ -6,27 +5,12 @@ import { normalizeDuration } from "../../common/datetime/normalize_duration";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { DEFAULT_ENTITY_NAME } from "../../common/entity/compute_entity_name_display";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { HomeAssistantFormatters } from "../../types";
import type { AreaRegistryEntry } from "../area/area_registry";
import type { DeviceRegistryEntry } from "../device/device_registry";
import type { FloorRegistryEntry } from "../floor_registry";
import type { HomeAssistant } from "../../types";
import type { Selector } from "../selector";
import { getDurationSelectorMode, getDurationSelectorUnits } from "../selector";
import type { FrontendLocaleData } from "../translation";
export interface FormatSelectorValueOptions {
locale: FrontendLocaleData;
localize: LocalizeFunc;
states: HassEntities;
devices: Record<string, DeviceRegistryEntry>;
areas: Record<string, AreaRegistryEntry>;
floors: Record<string, FloorRegistryEntry>;
formatEntityName: HomeAssistantFormatters["formatEntityName"];
}
export const formatSelectorValue = (
options: FormatSelectorValueOptions,
hass: HomeAssistant,
value: any,
selector?: Selector
) => {
@@ -63,7 +47,7 @@ export const formatSelectorValue = (
return number;
}
return unit_of_measurement
? `${num}${blankBeforeUnit(unit_of_measurement, options.locale)}${unit_of_measurement}`
? `${num}${blankBeforeUnit(unit_of_measurement, hass.locale)}${unit_of_measurement}`
: num.toString();
})
.join(", ");
@@ -73,7 +57,7 @@ export const formatSelectorValue = (
const floors = ensureArray(value);
return floors
.map((floorId) => {
const floor = options.floors[floorId];
const floor = hass.floors[floorId];
if (!floor) {
return floorId;
}
@@ -86,7 +70,7 @@ export const formatSelectorValue = (
const areas = ensureArray(value);
return areas
.map((areaId) => {
const area = options.areas[areaId];
const area = hass.areas[areaId];
if (!area) {
return areaId;
}
@@ -99,11 +83,11 @@ export const formatSelectorValue = (
const entities = ensureArray(value);
return entities
.map((entityId) => {
const stateObj = options.states[entityId];
const stateObj = hass.states[entityId];
if (!stateObj) {
return entityId;
}
const name = options.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
const name = hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
return name || entityId;
})
.join(", ");
@@ -113,7 +97,7 @@ export const formatSelectorValue = (
const devices = ensureArray(value);
return devices
.map((deviceId) => {
const device = options.devices[deviceId];
const device = hass.devices[deviceId];
if (!device) {
return deviceId;
}
@@ -141,7 +125,7 @@ export const formatSelectorValue = (
return Object.entries(fields)
.filter(([key]) => key in item && item[key] != null)
.map(([key, field]) =>
formatSelectorValue(options, item[key], field.selector)
formatSelectorValue(hass, item[key], field.selector)
)
.join(" = ");
}
@@ -159,13 +143,13 @@ export const formatSelectorValue = (
data,
getDurationSelectorUnits(selector.duration)
);
const formatted = formatDurationLong(options.locale, duration);
const formatted = formatDurationLong(hass.locale, duration);
const mode = getDurationSelectorMode(selector.duration);
if (!formatted || mode === "positive") {
return formatted;
}
const sign = negative ? "negative" : "positive";
return options.localize(
return hass.localize(
`ui.components.selectors.duration.summary.${mode}_${sign}`,
{ duration: formatted }
);
+70 -83
View File
@@ -1,40 +1,34 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import { beforeEach, describe, expect, it } from "vitest";
import { getStates } from "../../../src/common/entity/get_states";
import type { HomeAssistant } from "../../../src/types";
describe("getStates", () => {
let mockHass: HomeAssistant;
let mockStates: HassEntities;
beforeEach(() => {
mockHass = {
states: {
"zone.home": {
entity_id: "zone.home",
state: "zoning",
attributes: {
friendly_name: "Home",
},
} as HassEntity,
"zone.work": {
entity_id: "zone.work",
state: "zoning",
attributes: {
friendly_name: "Work",
},
} as HassEntity,
"zone.office": {
entity_id: "zone.office",
state: "zoning",
attributes: {
friendly_name: "Office",
},
} as HassEntity,
},
locale: {
language: "en",
},
} as any;
mockStates = {
"zone.home": {
entity_id: "zone.home",
state: "zoning",
attributes: {
friendly_name: "Home",
},
} as HassEntity,
"zone.work": {
entity_id: "zone.work",
state: "zoning",
attributes: {
friendly_name: "Work",
},
} as HassEntity,
"zone.office": {
entity_id: "zone.office",
state: "zoning",
attributes: {
friendly_name: "Office",
},
} as HassEntity,
};
});
const createMockEntity = (
@@ -53,7 +47,7 @@ describe("getStates", () => {
describe("Fixed domain states", () => {
it("should return alarm control panel states", () => {
const entity = createMockEntity("alarm_control_panel.test");
const result = getStates(mockHass, entity);
const result = getStates(mockStates, "en", entity);
expect(result).toEqual(
expect.arrayContaining([
@@ -76,7 +70,7 @@ describe("getStates", () => {
it("should return light states", () => {
const entity = createMockEntity("light.test");
const result = getStates(mockHass, entity);
const result = getStates(mockStates, "en", entity);
expect(result).toContain("on");
expect(result).toContain("off");
@@ -87,7 +81,7 @@ describe("getStates", () => {
it("should return empty array plus unavailable states for button domain", () => {
const entity = createMockEntity("button.test");
const result = getStates(mockHass, entity);
const result = getStates(mockStates, "en", entity);
expect(result).toContain("unavailable");
expect(result).toContain("unknown");
@@ -96,7 +90,7 @@ describe("getStates", () => {
it("should return weather states", () => {
const entity = createMockEntity("weather.test");
const result = getStates(mockHass, entity);
const result = getStates(mockStates, "en", entity);
expect(result).toEqual(
expect.arrayContaining([
@@ -126,7 +120,7 @@ describe("getStates", () => {
describe("Fixed domain attribute states", () => {
it("should return binary sensor device classes", () => {
const entity = createMockEntity("binary_sensor.test");
const result = getStates(mockHass, entity, "device_class");
const result = getStates(mockStates, "en", entity, "device_class");
expect(result).toEqual(
expect.arrayContaining([
@@ -166,7 +160,7 @@ describe("getStates", () => {
it("should return media player device classes", () => {
const entity = createMockEntity("media_player.test");
const result = getStates(mockHass, entity, "device_class");
const result = getStates(mockStates, "en", entity, "device_class");
expect(result).toContain("tv");
expect(result).toContain("speaker");
@@ -176,7 +170,7 @@ describe("getStates", () => {
it("should return sensor device classes", () => {
const entity = createMockEntity("sensor.test");
const result = getStates(mockHass, entity, "device_class");
const result = getStates(mockStates, "en", entity, "device_class");
expect(result).toEqual(
expect.arrayContaining([
@@ -223,7 +217,7 @@ describe("getStates", () => {
it("should return empty array for unknown attribute", () => {
const entity = createMockEntity("light.test");
const result = getStates(mockHass, entity, "unknown_attribute");
const result = getStates(mockStates, "en", entity, "unknown_attribute");
expect(result).toEqual([]);
});
@@ -234,7 +228,7 @@ describe("getStates", () => {
const entity = createMockEntity("climate.test", "off", {
hvac_modes: ["heat", "cool", "auto", "off"],
});
const result = getStates(mockHass, entity);
const result = getStates(mockStates, "en", entity);
expect(result).toContain("heat");
expect(result).toContain("cool");
@@ -249,7 +243,7 @@ describe("getStates", () => {
const entity = createMockEntity("climate.test", "heat", {
fan_modes: ["low", "medium", "high", "auto"],
});
const result = getStates(mockHass, entity, "fan_mode");
const result = getStates(mockStates, "en", entity, "fan_mode");
expect(result).toContain("low");
expect(result).toContain("medium");
@@ -262,7 +256,7 @@ describe("getStates", () => {
const entity = createMockEntity("climate.test", "heat", {
preset_modes: ["eco", "comfort", "sleep"],
});
const result = getStates(mockHass, entity, "preset_mode");
const result = getStates(mockStates, "en", entity, "preset_mode");
expect(result).toContain("eco");
expect(result).toContain("comfort");
@@ -274,7 +268,7 @@ describe("getStates", () => {
const entity = createMockEntity("climate.test", "cool", {
swing_modes: ["on", "off", "vertical", "horizontal"],
});
const result = getStates(mockHass, entity, "swing_mode");
const result = getStates(mockStates, "en", entity, "swing_mode");
expect(result).toContain("on");
expect(result).toContain("off");
@@ -287,7 +281,7 @@ describe("getStates", () => {
describe("Device tracker and person zones", () => {
it("should return zones for device_tracker without attributes", () => {
const entity = createMockEntity("device_tracker.test");
const result = getStates(mockHass, entity);
const result = getStates(mockStates, "en", entity);
expect(result).toContain("home");
expect(result).toContain("not_home");
@@ -300,7 +294,7 @@ describe("getStates", () => {
it("should return zones for person without attributes", () => {
const entity = createMockEntity("person.test");
const result = getStates(mockHass, entity);
const result = getStates(mockStates, "en", entity);
expect(result).toContain("home");
expect(result).toContain("not_home");
@@ -313,7 +307,7 @@ describe("getStates", () => {
it("should not return zones for device_tracker with attributes", () => {
const entity = createMockEntity("device_tracker.test");
const result = getStates(mockHass, entity, "source_type");
const result = getStates(mockStates, "en", entity, "source_type");
expect(result).toContain("bluetooth");
expect(result).toContain("bluetooth_le");
@@ -328,7 +322,7 @@ describe("getStates", () => {
const entity = createMockEntity("input_select.test", "option1", {
options: ["option1", "option2", "option3"],
});
const result = getStates(mockHass, entity);
const result = getStates(mockStates, "en", entity);
expect(result).toContain("option1");
expect(result).toContain("option2");
@@ -342,7 +336,7 @@ describe("getStates", () => {
const entity = createMockEntity("select.test", "mode1", {
options: ["mode1", "mode2", "mode3"],
});
const result = getStates(mockHass, entity);
const result = getStates(mockStates, "en", entity);
expect(result).toContain("mode1");
expect(result).toContain("mode2");
@@ -358,7 +352,7 @@ describe("getStates", () => {
const entity = createMockEntity("light.test", "on", {
effect_list: ["rainbow", "colorloop", "pulse"],
});
const result = getStates(mockHass, entity, "effect");
const result = getStates(mockStates, "en", entity, "effect");
expect(result).toContain("rainbow");
expect(result).toContain("colorloop");
@@ -370,7 +364,7 @@ describe("getStates", () => {
const entity = createMockEntity("light.test", "on", {
supported_color_modes: ["rgb", "xy", "hs"],
});
const result = getStates(mockHass, entity, "color_mode");
const result = getStates(mockStates, "en", entity, "color_mode");
expect(result).toContain("rgb");
expect(result).toContain("xy");
@@ -380,7 +374,7 @@ describe("getStates", () => {
it("should return empty for light effect without effect_list", () => {
const entity = createMockEntity("light.test", "on", {});
const result = getStates(mockHass, entity, "effect");
const result = getStates(mockStates, "en", entity, "effect");
expect(result).toEqual([]);
});
@@ -391,7 +385,7 @@ describe("getStates", () => {
const entity = createMockEntity("media_player.test", "playing", {
sound_mode_list: ["stereo", "surround", "music"],
});
const result = getStates(mockHass, entity, "sound_mode");
const result = getStates(mockStates, "en", entity, "sound_mode");
expect(result).toContain("stereo");
expect(result).toContain("surround");
@@ -403,7 +397,7 @@ describe("getStates", () => {
const entity = createMockEntity("media_player.test", "playing", {
source_list: ["spotify", "radio", "cd"],
});
const result = getStates(mockHass, entity, "source");
const result = getStates(mockStates, "en", entity, "source");
expect(result).toContain("spotify");
expect(result).toContain("radio");
@@ -418,7 +412,7 @@ describe("getStates", () => {
device_class: "enum",
options: ["low", "medium", "high"],
});
const result = getStates(mockHass, entity);
const result = getStates(mockStates, "en", entity);
expect(result).toContain("low");
expect(result).toContain("medium");
@@ -433,7 +427,7 @@ describe("getStates", () => {
device_class: "temperature",
options: ["low", "medium", "high"], // This shouldn't be used
});
const result = getStates(mockHass, entity);
const result = getStates(mockStates, "en", entity);
expect(result).toContain("unavailable");
expect(result).toContain("unknown");
@@ -446,7 +440,7 @@ describe("getStates", () => {
const entity = createMockEntity("event.test", "triggered", {
event_types: ["button_press", "motion", "door_open"],
});
const result = getStates(mockHass, entity, "event_type");
const result = getStates(mockStates, "en", entity, "event_type");
expect(result).toContain("button_press");
expect(result).toContain("motion");
@@ -458,7 +452,7 @@ describe("getStates", () => {
const entity = createMockEntity("fan.test", "on", {
preset_modes: ["breeze", "sleep", "auto"],
});
const result = getStates(mockHass, entity, "preset_mode");
const result = getStates(mockStates, "en", entity, "preset_mode");
expect(result).toContain("breeze");
expect(result).toContain("sleep");
@@ -470,7 +464,7 @@ describe("getStates", () => {
const entity = createMockEntity("humidifier.test", "on", {
available_modes: ["normal", "eco", "boost"],
});
const result = getStates(mockHass, entity, "mode");
const result = getStates(mockStates, "en", entity, "mode");
expect(result).toContain("normal");
expect(result).toContain("eco");
@@ -482,7 +476,7 @@ describe("getStates", () => {
const entity = createMockEntity("remote.test", "on", {
activity_list: ["watch_tv", "listen_music", "gaming"],
});
const result = getStates(mockHass, entity, "current_activity");
const result = getStates(mockStates, "en", entity, "current_activity");
expect(result).toContain("watch_tv");
expect(result).toContain("listen_music");
@@ -494,7 +488,7 @@ describe("getStates", () => {
const entity = createMockEntity("vacuum.test", "cleaning", {
fan_speed_list: ["quiet", "standard", "high"],
});
const result = getStates(mockHass, entity, "fan_speed");
const result = getStates(mockStates, "en", entity, "fan_speed");
expect(result).toContain("quiet");
expect(result).toContain("standard");
@@ -506,7 +500,7 @@ describe("getStates", () => {
const entity = createMockEntity("water_heater.test", "eco", {
operation_list: ["eco", "electric", "gas"],
});
const result = getStates(mockHass, entity);
const result = getStates(mockStates, "en", entity);
expect(result).toContain("eco");
expect(result).toContain("electric");
@@ -520,7 +514,7 @@ describe("getStates", () => {
const entity = createMockEntity("water_heater.test", "eco", {
operation_list: ["eco", "electric", "gas"],
});
const result = getStates(mockHass, entity, "operation_mode");
const result = getStates(mockStates, "en", entity, "operation_mode");
expect(result).toContain("eco");
expect(result).toContain("electric");
@@ -532,7 +526,7 @@ describe("getStates", () => {
describe("Edge cases and boundary conditions", () => {
it("should handle missing attributes gracefully", () => {
const entity = createMockEntity("light.test", "off", {});
const result = getStates(mockHass, entity);
const result = getStates(mockStates, "en", entity);
// Should still return fixed states and unavailable states
expect(result).toContain("on");
@@ -546,14 +540,14 @@ describe("getStates", () => {
const entity = createMockEntity("light.test", "on", {
effect_list: undefined,
});
const result = getStates(mockHass, entity, "effect");
const result = getStates(mockStates, "en", entity, "effect");
expect(result).toEqual([]);
});
it("should not include unavailable states when attribute is specified", () => {
const entity = createMockEntity("binary_sensor.test");
const result = getStates(mockHass, entity, "device_class");
const result = getStates(mockStates, "en", entity, "device_class");
expect(result).not.toContain("unavailable");
expect(result).not.toContain("unknown");
@@ -563,20 +557,16 @@ describe("getStates", () => {
const entity = createMockEntity("light.test", "on", {
// Simulate case where fixed and dynamic states might overlap
});
const result = getStates(mockHass, entity);
const result = getStates(mockStates, "en", entity);
// Each state should only appear once
const uniqueStates = [...new Set(result)];
expect(result.length).toBe(uniqueStates.length);
});
it("should handle empty hass.states object", () => {
const emptyHass = {
...mockHass,
states: {},
};
it("should handle empty states object", () => {
const entity = createMockEntity("device_tracker.test");
const result = getStates(emptyHass, entity);
const result = getStates({}, "en", entity);
// Should still return fixed states
expect(result).toContain("home");
@@ -587,19 +577,16 @@ describe("getStates", () => {
});
it("names zones without friendly_name after their object id", () => {
const hassWithoutFriendlyName = {
...mockHass,
states: {
...mockHass.states,
"zone.no_name": {
entity_id: "zone.no_name",
state: "zoning",
attributes: {}, // No friendly_name
} as HassEntity,
},
const statesWithoutFriendlyName = {
...mockStates,
"zone.no_name": {
entity_id: "zone.no_name",
state: "zoning",
attributes: {}, // No friendly_name
} as HassEntity,
};
const entity = createMockEntity("device_tracker.test");
const result = getStates(hassWithoutFriendlyName, entity);
const result = getStates(statesWithoutFriendlyName, "en", entity);
expect(result).toEqual(
expect.arrayContaining([
@@ -1,16 +1,6 @@
import { ContextProvider } from "@lit/context";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
formattersContext,
internationalizationContext,
registriesContext,
statesContext,
} from "../../../src/data/context";
import type { ObjectSelector } from "../../../src/data/selector";
import type {
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../../../src/types";
import type { HomeAssistant } from "../../../src/types";
import type { HaObjectSelector } from "../../../src/components/ha-selector/ha-selector-object";
import type {
FormDialogData,
@@ -43,6 +33,16 @@ vi.mock("../../../src/components/ha-yaml-editor", () => {
return {};
});
const hass = {
localize: (key: string) => key,
locale: "en-US",
floors: {},
areas: {},
devices: {},
states: {},
formatEntityName: () => "",
} as unknown as HomeAssistant;
const selectorConfig = {
object: {
multiple: true,
@@ -67,32 +67,13 @@ const mountSelector = async (
value: FormDialogData | FormDialogData[] | "",
config: ObjectSelector = selectorConfig
) => {
const host = document.createElement("div");
new ContextProvider(host, {
context: internationalizationContext,
initialValue: {
localize: (key: string) => key,
locale: { language: "en" },
} as unknown as HomeAssistantInternationalization,
});
new ContextProvider(host, {
context: registriesContext,
initialValue: { entities: {}, devices: {}, areas: {}, floors: {} },
});
new ContextProvider(host, { context: statesContext, initialValue: {} });
new ContextProvider(host, {
context: formattersContext,
initialValue: {
formatEntityName: () => "",
} as unknown as HomeAssistantFormatters,
});
const selector = document.createElement(
"ha-selector-object"
) as HaObjectSelector;
selector.hass = hass;
selector.selector = config;
selector.value = value;
host.append(selector);
document.body.append(host);
document.body.append(selector);
await selector.updateComplete;
return selector;
};
+24 -27
View File
@@ -1,34 +1,33 @@
import { describe, it, expect } from "vitest";
import {
formatSelectorValue,
type FormatSelectorValueOptions,
} from "../../src/data/selector/format_selector_value";
import { formatSelectorValue } from "../../src/data/selector/format_selector_value";
import type { HomeAssistant } from "../../src/types";
// These selectors read none of the options.
const options = {} as FormatSelectorValueOptions;
// formatSelectorValue only touches hass for floor/area/entity/device
// selectors, none of which these tests exercise.
const hass = {} as HomeAssistant;
describe("formatSelectorValue", () => {
it("returns an empty string for nullish values", () => {
expect(formatSelectorValue(options, null)).toBe("");
expect(formatSelectorValue(options, undefined)).toBe("");
expect(formatSelectorValue(hass, null)).toBe("");
expect(formatSelectorValue(hass, undefined)).toBe("");
});
it("renders a plain text value", () => {
expect(
formatSelectorValue(options, "hello", { text: { type: "text" } })
).toBe("hello");
expect(formatSelectorValue(hass, "hello", { text: { type: "text" } })).toBe(
"hello"
);
});
it("applies prefix and suffix for text selectors", () => {
expect(
formatSelectorValue(options, "5", {
formatSelectorValue(hass, "5", {
text: { type: "text", prefix: "$", suffix: " each" },
})
).toBe("$5 each");
});
it("masks a password text value instead of revealing it", () => {
const result = formatSelectorValue(options, "hunter2", {
const result = formatSelectorValue(hass, "hunter2", {
text: { type: "password" },
});
expect(result).not.toContain("hunter2");
@@ -36,7 +35,7 @@ describe("formatSelectorValue", () => {
});
it("masks every value of a multiple password selector", () => {
const result = formatSelectorValue(options, ["one", "two"], {
const result = formatSelectorValue(hass, ["one", "two"], {
text: { type: "password" },
});
expect(result).not.toContain("one");
@@ -46,7 +45,7 @@ describe("formatSelectorValue", () => {
it("masks a nested password field in an object selector preview", () => {
const result = formatSelectorValue(
options,
hass,
{ username: "admin", password: "hunter2" },
{
object: {
@@ -64,7 +63,7 @@ describe("formatSelectorValue", () => {
});
describe("formatSelectorValue duration selector", () => {
const localizedOptions = {
const localizedHass = {
locale: { language: "en" },
localize: (key: string, values?: Record<string, unknown>) => {
const suffix = key.split("ui.components.selectors.duration.summary.")[1];
@@ -75,12 +74,12 @@ describe("formatSelectorValue duration selector", () => {
signed_positive: `+${values?.duration}`,
}[suffix]!;
},
} as unknown as FormatSelectorValueOptions;
} as unknown as HomeAssistant;
it("formats a positive duration without a sign", () => {
expect(
formatSelectorValue(
localizedOptions,
localizedHass,
{ hours: 1, minutes: 30 },
{ duration: {} }
)
@@ -90,13 +89,13 @@ describe("formatSelectorValue duration selector", () => {
it("formats signed durations with an explicit sign", () => {
expect(
formatSelectorValue(
localizedOptions,
localizedHass,
{ minutes: -30 },
{ duration: { mode: "signed" } }
)
).toBe("-30 minutes");
expect(
formatSelectorValue(localizedOptions, "00:30:00", {
formatSelectorValue(localizedHass, "00:30:00", {
duration: { allow_negative: true },
})
).toBe("+30 minutes");
@@ -105,24 +104,22 @@ describe("formatSelectorValue duration selector", () => {
it("formats offsets as before or after", () => {
expect(
formatSelectorValue(
localizedOptions,
localizedHass,
{ hours: -1, minutes: -30 },
{ duration: { mode: "offset" } }
)
).toBe("1 hour, 30 minutes before");
expect(
formatSelectorValue(localizedOptions, "-00:10:00", {
formatSelectorValue(localizedHass, "-00:10:00", {
duration: { mode: "offset" },
})
).toBe("10 minutes before");
expect(
formatSelectorValue(localizedOptions, 45, {
duration: { mode: "offset" },
})
formatSelectorValue(localizedHass, 45, { duration: { mode: "offset" } })
).toBe("45 seconds after");
expect(
formatSelectorValue(
localizedOptions,
localizedHass,
{ minutes: -5 },
{ duration: { mode: "offset" } }
)
@@ -132,7 +129,7 @@ describe("formatSelectorValue duration selector", () => {
it("returns an empty string for a zero offset", () => {
expect(
formatSelectorValue(
localizedOptions,
localizedHass,
{ hours: 0, minutes: 0, seconds: 0 },
{ duration: { mode: "offset" } }
)