Files
frontend/test/data/scene.test.ts
0b34fcb559 Show target-state entity icon in scene editor review mode (#52520)
* Show target-state entity icon in scene editor review mode

Render the entity badge in review mode (previously live-only) across both
the device-grouped and standalone entity lists. In review mode the badge
uses the scene's stored target state instead of the entity's live state, so
the icon previews what the scene will set once applied.

* Strip stale entity_picture from the synthesized review-mode state

state-badge hides the icon and renders a background image whenever a state
carries an entity_picture (see its willUpdate). A scene snapshots that URL
with an access token that is stale by the time review mode renders, so an
image-backed entity - for example a robot vacuum's "Map data" camera entity
on its device - showed a blank badge in review mode instead of an icon.

Drop entity_picture / entity_picture_local from the synthesized state so the
entity's own icon resolves. This is not a pre-existing defect: it handles a
case that rendering the badge in review mode (previous commit) introduces.

* Handle null and scalar scene entity values in review mode badges

An entity left without a value in the YAML editor parses as null, which
crashed the review-mode render. The scene config API also returns raw
scenes.yaml content without validation, so hand-edited scenes deliver
boolean states as-is (YAML 1.1 parses unquoted on/off as booleans);
these previously rendered as if the entity had no state at all.

Booleans map to on/off to match how the backend applies them when a
scene is activated (_convert_states in the homeassistant scene
platform). The backend rejects null and numeric states at save, but
review mode renders before save, so the frontend has to tolerate them.

* Extract and memoize the scene target-state synthesis

The editor re-renders on every hass change, and building a fresh state
object per row each time defeated Lit dirty-checking: every state-badge
re-ran willUpdate and every ha-state-icon restarted its async icon
resolution. Memoizing the synthesized objects per config keeps the
references stable so unchanged badges skip all of that.

Moving the synthesis to src/data/scene.ts makes it unit-testable; the
null, boolean, numeric, string, and picture-stripping cases are now
covered by tests.

* Sanitize brightness and rgb_color in the synthesized review-mode state

state-badge does arithmetic on brightness and joins rgb_color, assuming
backend-shaped values. Hand-typed YAML can hold both as strings: a
string rgb_color threw a TypeError that left the badge blank, and a
string brightness computed a brightness(36049%) filter that washed the
icon out to invisible. Coerce numeric-string brightness and drop
malformed values so the badge always renders the target state.

* Borrow the live device_class for review-mode badge icons

Icon resolution keys on device_class, which string-shorthand and
hand-written minimal scene entries do not carry, so a garage cover fell
back to the generic window icon and sensors to the domain default. Only
this identity attribute is borrowed from the live state - merging
stateful attributes like rgb_color would mis-color an off target.

* Reject unusable scene targets and trim the badge-state synthesis

Review-mode badges now render only when the scene holds a usable
target state. Entries with no state to show - null values, dicts
without a state key, arrays, non-scalar states - yield no badge
instead of falling back to the live state, which was
indistinguishable from a real target and, for dicts without a state,
crashed state-badge via stateColorCss on lights.

rgb_color and brightness are dropped from inactive targets: a live
entity never carries them while off, and state-badge applies them
without checking activity, so a scene turning a light off rendered an
active-looking colored icon.

Entity pictures are stripped only for DOMAINS_WITH_DYNAMIC_PICTURE,
matching createHistoricState in the logbook; stable pictures on other
domains are kept.

The brightness/rgb_color type coercion and the live device_class
borrowing are removed: they defended against hand-typed shapes that
state-badge already warns about, and the borrowing made the memoized
synthesis depend on hass state outside its memoize key.

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Przemysław Szypowicz <2733699+pszypowicz@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-08-06 11:37:39 +00:00

117 lines
3.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { sceneEntityStateObj } from "../../src/data/scene";
describe("sceneEntityStateObj", () => {
it("builds a state object from string shorthand", () => {
expect(sceneEntityStateObj("light.kitchen", "on")).toEqual({
entity_id: "light.kitchen",
state: "on",
attributes: {},
});
});
it("builds a state object from the dict form", () => {
expect(
sceneEntityStateObj("light.kitchen", {
state: "on",
brightness: 180,
rgb_color: [255, 64, 112],
})
).toEqual({
entity_id: "light.kitchen",
state: "on",
attributes: { brightness: 180, rgb_color: [255, 64, 112] },
});
});
it("strips media-derived entity pictures but keeps other attributes", () => {
const sceneEntity = {
state: "playing",
entity_picture: "/api/media_player_proxy/x?token=stale",
entity_picture_local: "/local/x.jpg",
friendly_name: "Player",
};
expect(sceneEntityStateObj("media_player.x", sceneEntity)).toEqual({
entity_id: "media_player.x",
state: "playing",
attributes: { friendly_name: "Player" },
});
// The input scene config must not be mutated.
expect(sceneEntity.entity_picture).toBe(
"/api/media_player_proxy/x?token=stale"
);
});
it("keeps a stable entity picture on other domains", () => {
expect(
sceneEntityStateObj("vacuum.robot", {
state: "cleaning",
entity_picture: "/local/robot.png",
})?.attributes.entity_picture
).toBe("/local/robot.png");
});
it("returns undefined for null and undefined values", () => {
// An entity left without a value in the YAML editor parses as null.
expect(sceneEntityStateObj("light.kitchen", null)).toBeUndefined();
expect(sceneEntityStateObj("light.kitchen", undefined)).toBeUndefined();
});
it("returns undefined for an array value", () => {
expect(sceneEntityStateObj("light.kitchen", [255, 100])).toBeUndefined();
});
it("returns undefined for a numeric value", () => {
// The backend only accepts string and boolean states, so a number never
// becomes a target the scene can apply.
expect(sceneEntityStateObj("input_number.x", 23)).toBeUndefined();
expect(
sceneEntityStateObj("input_number.x", { state: 23 })
).toBeUndefined();
});
it("returns undefined when the dict holds no usable state", () => {
expect(
sceneEntityStateObj("light.kitchen", { brightness: 100 })
).toBeUndefined();
expect(
sceneEntityStateObj("light.kitchen", { state: null })
).toBeUndefined();
expect(
sceneEntityStateObj("light.kitchen", { state: { brightness: 100 } })
).toBeUndefined();
});
it("normalizes boolean shorthand to on/off like the backend does", () => {
// YAML 1.1 parses unquoted on/off in scenes.yaml as booleans.
expect(sceneEntityStateObj("light.kitchen", true)?.state).toBe("on");
expect(sceneEntityStateObj("light.kitchen", false)?.state).toBe("off");
});
it("normalizes a boolean state in the dict form", () => {
expect(sceneEntityStateObj("light.kitchen", { state: true })).toEqual({
entity_id: "light.kitchen",
state: "on",
attributes: {},
});
});
it("drops color attributes for an inactive target", () => {
// A live entity never carries these while off, and state-badge applies
// them without checking activity.
expect(
sceneEntityStateObj("light.kitchen", {
state: "off",
brightness: 180,
rgb_color: [255, 0, 0],
friendly_name: "Kitchen",
})
).toEqual({
entity_id: "light.kitchen",
state: "off",
attributes: { friendly_name: "Kitchen" },
});
});
});