mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-23 22:24:24 +00:00
* 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]>
178 lines
5.1 KiB
TypeScript
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");
|
|
});
|
|
});
|