Files
frontend/test/data/state-description.test.ts
3a9366efb3 Automation editor: support for negated state trigger (#27704)
* Support negated state triggers in the automation editor

Add "Is" / "Is not" choices to the from and to fields of the state
trigger, so not_from and not_to can be edited in the UI. Built on the
choose selector, which now forwards context to its inner selector.

Co-Authored-By: Niklas Wagner <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Support negated state triggers in the HaStateTrigger and update tests for description

* Move from/not_from exclusivity check into checkUiSupport

The row and sidebar use the static check for their YAML mode warnings,
so a trigger with both from and not_from (or to and not_to) now shows
the warning there too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep state trigger keys in place and avoid from A to A

Saving a match value no longer moves from/to to the end of the trigger,
so editing an unrelated field doesn't reorder the saved config.

When switching back from "Is not" to "Is", the states the other field
already matches are dropped, since a trigger from A to A never fires.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Don't modify ha-form's data in the state trigger editor

The value-changed handler rewrote ha-form's own data object, turning the
match values into plain arrays. When ha-form fired again before fresh data
was passed down, like the attribute selector clearing the attribute after
an entity change, from/to/not_from/not_to were dropped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Test that the state trigger editor survives a repeated ha-form event

Guards against rewriting ha-form's data again: a second event before new
data is passed down, like the attribute being cleared after an entity
change, must keep from/to and not_from/not_to.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Bram Kragten <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-10-22 08:24:27 +02:00

178 lines
5.1 KiB
TypeScript

import { IntlMessageFormat } from "intl-messageformat";
import { describe, expect, it } from "vitest";
import type {
Condition,
Trigger,
TriggerCondition,
} from "../../src/data/automation";
import {
describeCondition,
describeTrigger,
} from "../../src/data/automation_i18n";
import {
DateFormat,
FirstWeekday,
NumberFormat,
TimeFormat,
TimeZone,
} from "../../src/data/translation";
import { describeAction } from "../../src/data/script_i18n";
import en from "../../src/translations/en.json";
import type { HomeAssistant } from "../../src/types";
type TranslationNode = string | { [key: string]: TranslationNode };
const localize = (key: string, values?: Record<string, unknown>) => {
const message = key
.split(".")
.reduce<TranslationNode | undefined>(
(translations, part) =>
typeof translations === "object" ? translations[part] : undefined,
en as TranslationNode
);
return typeof message === "string"
? (new IntlMessageFormat(message, "en").format(values) as string)
: "";
};
const hass = {
localize,
locale: {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.twenty_four,
date_format: DateFormat.language,
first_weekday: FirstWeekday.language,
time_zone: TimeZone.local,
},
config: { time_zone: "Etc/UTC" },
states: {
"light.kitchen": {
entity_id: "light.kitchen",
state: "on",
attributes: { friendly_name: "Kitchen light" },
},
"sensor.temperature": {
entity_id: "sensor.temperature",
state: "21",
attributes: { friendly_name: "Temperature" },
},
},
entities: {},
formatEntityState: (_stateObj, state?: string) => state ?? "",
} as unknown as HomeAssistant;
const describeRowTrigger = (trigger: Trigger) =>
describeTrigger(trigger, hass, []);
const describeRowCondition = (condition: Condition) =>
describeCondition(condition, hass, []);
describe("describing state triggers and conditions", () => {
const trigger: Trigger = {
trigger: "state",
entity_id: "light.kitchen",
to: "on",
};
const condition: Condition = {
condition: "state",
entity_id: "light.kitchen",
state: "on",
};
it("leaves the entities out, they are rendered as targets", () => {
expect(describeRowTrigger(trigger)).toBe("State changed to on");
expect(describeRowCondition(condition)).toBe("State is on");
});
it("describes negated from and to states", () => {
expect(
describeRowTrigger({
trigger: "state",
entity_id: "light.kitchen",
not_from: ["unavailable", "unknown"],
})
).toBe("State changed from any state except unavailable and unknown");
expect(
describeRowTrigger({
trigger: "state",
entity_id: "light.kitchen",
from: "off",
not_to: "on",
})
).toBe("State changed from off to any state except on");
});
it("falls back to the label when nothing is configured yet", () => {
expect(
describeRowTrigger({ trigger: "state", entity_id: "light.kitchen" })
).toBe("State or any attribute changed");
expect(
describeRowCondition({
condition: "state",
entity_id: "light.kitchen",
state: [],
})
).toBe("State");
});
});
describe("describing numeric state triggers and conditions", () => {
const trigger: Trigger = {
trigger: "numeric_state",
entity_id: "sensor.temperature",
above: 20,
};
const condition: Condition = {
condition: "numeric_state",
entity_id: "sensor.temperature",
above: 20,
};
it("leaves the entities out, they are rendered as targets", () => {
expect(describeRowTrigger(trigger)).toBe("Numeric state crossed above 20");
expect(describeRowCondition(condition)).toBe("Numeric state is above 20");
});
it("describes both thresholds", () => {
expect(describeRowTrigger({ ...trigger, below: 30 })).toBe(
"Numeric state crossed above 20 and below 30"
);
expect(describeRowCondition({ ...condition, below: 30 })).toBe(
"Numeric state is above 20 and below 30"
);
});
it("falls back to the label without a threshold", () => {
expect(
describeRowTrigger({
trigger: "numeric_state",
entity_id: "sensor.temperature",
})
).toBe("Numeric state crossed threshold");
expect(
describeRowCondition({
condition: "numeric_state",
entity_id: "sensor.temperature",
})
).toBe("Numeric state");
});
});
describe("standalone trigger-condition descriptions", () => {
it("leaves the IDs out and preserves aliases", () => {
const condition: TriggerCondition = {
condition: "trigger",
id: ["motion", "timer"],
};
expect(describeCondition(condition, hass, [])).toBe("If triggered by");
expect(describeAction(hass, [], condition)).toBe("Test: If triggered by");
const aliased = { ...condition, alias: "Custom label" };
expect(describeCondition(aliased, hass, [])).toBe("Custom label");
expect(describeAction(hass, [], aliased)).toBe("Custom label");
expect(
describeAction(hass, [], aliased, undefined, { ignoreAlias: true })
).toBe("Test: If triggered by");
});
});