Compare commits

..
1 Commits
14 changed files with 96 additions and 497 deletions
+4 -33
View File
@@ -60,7 +60,7 @@ const generateSumStatistics = (
end: Date,
period: "5minute" | "hour" | "day" | "month" = "hour",
initValue: number,
maxDiff: number | ((start: Date, end: Date) => number)
maxDiff: number
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = new Date(start);
@@ -69,9 +69,7 @@ const generateSumStatistics = (
const now = new Date();
while (end > currentDate && currentDate < now) {
const nextDate = getNextDate(currentDate, period);
const bound =
typeof maxDiff === "function" ? maxDiff(currentDate, nextDate) : maxDiff;
const add = Math.random() * bound;
const add = Math.random() * maxDiff;
sum += add;
statistics.push({
start: currentDate.getTime(),
@@ -138,15 +136,6 @@ const statisticsFunctions: Record<
period: "5minute" | "hour" | "day" | "month"
) => StatisticValue[]
> = {
"sensor.energy_water": (_id, start, end, period = "hour") =>
generateSumStatistics(
start,
end,
period,
4000,
(bucketStart, bucketEnd) =>
(20 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
),
"sensor.energy_consumption_tarif_1": (
_id: string,
start: Date,
@@ -312,11 +301,6 @@ const statisticsFunctions: Record<
return [...morning, ...production, ...evening, ...rest];
},
};
// Prices are per source unit; water uses litres (4 currency units per m³).
const derivedCosts: Record<string, { source: string; price: number }> = {
"sensor.energy_water_cost": { source: "sensor.energy_water", price: 0.004 },
};
export const mockRecorder = (mockHass: MockHomeAssistant) => {
mockHass.mockWS(
"recorder/get_statistics_metadata",
@@ -333,23 +317,10 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
const end = end_time ? new Date(end_time) : new Date();
const statistics: Record<string, StatisticValue[]> = {};
const cache: Record<string, StatisticValue[]> = {};
const getStats = (id: string) => {
cache[id] ??= statisticsFunctions[id](id, start, end, period);
return cache[id];
};
statistic_ids.forEach((id: string) => {
if (id in derivedCosts) {
const { source, price } = derivedCosts[id];
let cost = 0;
statistics[id] = getStats(source).map((statistic) => {
const change = statistic.change! * price;
cost += change;
return { ...statistic, change, state: cost, sum: cost };
});
} else if (id in statisticsFunctions) {
statistics[id] = getStats(id);
if (id in statisticsFunctions) {
statistics[id] = statisticsFunctions[id](id, start, end, period);
} else {
const entityState = hass.states[id];
const state = entityState ? Number(entityState.state) : 1;
+3 -3
View File
@@ -85,7 +85,7 @@
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@versatiles/style": "6.3.0",
"@versatiles/style": "6.1.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -113,7 +113,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "6.13.0",
"maplibre-gl": "6.12.0",
"marked": "18.1.0",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
@@ -225,7 +225,7 @@
"webpackbar": "7.0.0",
"workbox-build": "7.4.1"
},
"packageManager": "[email protected].1",
"packageManager": "[email protected].0",
"volta": {
"node": "24.21.0"
}
+79 -79
View File
@@ -7,153 +7,153 @@ importers:
configDependencies: {}
packageManagerDependencies:
pnpm:
specifier: 12.10.1
version: 12.10.1
specifier: 12.10.0
version: 12.10.0
packages:
'@pnpm/[email protected].1':
resolution: {integrity: sha512-CVTlHPwNvadQCJD9bGSMzWIhFX8QpjtqC2c27dhdnn/9iKzEjZ3qL62o3gHpKbIuo6qn4WxTKRoaokgNdReBig==}
'@pnpm/[email protected].0':
resolution: {integrity: sha512-QMMRzOI/7CU4xLbKY6O2BZn3VI4+a8LLyim18H7it8llBWuBPMqDqUURE8045k+7lI8/lI2/tElhyKptEviKDQ==}
cpu: [arm64]
os: [android]
'@pnpm/[email protected].1':
resolution: {integrity: sha512-NFiYLIaodY7da0XIebwcU3JoP7IK/lzF7zgKSSP+9H+QcFnQ1GDfBkuBKCYJzZ+Xu0WH54neIPLBBCaOjaaauA==}
'@pnpm/[email protected].0':
resolution: {integrity: sha512-41zwlROW+t523Nfqo4rWp8IPiSK5vnAWrnCey26Je45rX1SMNc4ABY7OFmPw41/t+Y0CE+4tjTj+0s9YGqPM5w==}
cpu: [x64]
os: [android]
'@pnpm/[email protected].1':
resolution: {integrity: sha512-0O8tdQ8ijlPJudXspLMTcitMkdBEz6uUflazL5uQE0WMs85oQIp0U+5hfEktMFB64QqEhjqlvvujTwbzdr9oMg==}
'@pnpm/[email protected].0':
resolution: {integrity: sha512-L+r7AD8Fjf8Vcc4ZRJl+Pjzc27CIeQ/FiRwPmOluFO2UcXubmGHdW+OuMUxrU0vkoXiGR61Ft0RtaPwTRY4W/g==}
cpu: [arm64]
os: [darwin]
'@pnpm/[email protected].1':
resolution: {integrity: sha512-ud+1/uZOKEo8P/AsPynOJc/LNwfI0j6WwCElibj71rU9l4nIBnsSn+wWTIPZPkZ4BBSH5+82azSnAARMyJ1lNA==}
'@pnpm/[email protected].0':
resolution: {integrity: sha512-/TCF1K6E+4PnbkLgqGS1Iqlf5bHp6FDzuXgu6++PMMAFkF7Yk5EF3BcfG1gYYjpkvlJBccvVMsRBnWFbSyqu1w==}
cpu: [x64]
os: [darwin]
'@pnpm/[email protected].1':
resolution: {integrity: sha512-mj9PE5rf8uAfQQ2K66HSATMSut+lNiB4eSqFd5+azFVCMnxNSy/32xpZQJT21PhnN3Y4qbK+iKHe5nWAxSuRhw==}
'@pnpm/[email protected].0':
resolution: {integrity: sha512-k7/WFA91E2qhnR/2EOEv402U8bjP758y2pybbdiB5Hrd4KhgviVOJp5cFrXpKgd8HZJs/RZN7cdmMvU2Q2BVgg==}
cpu: [x64]
os: [freebsd]
'@pnpm/[email protected].1':
resolution: {integrity: sha512-xba+00wZJ8Um6rBJJjWl88uJj0tydB8dtomvjlCLvi0uL32vpa9CRP8Vv26FIXvbiZ1bo2RCzYt/eChxJVV52w==}
'@pnpm/[email protected].0':
resolution: {integrity: sha512-ChD8bN0lRCRaL+CMOaMUgZQ4WHzEkDXmDHrZgXgdoo6yymQKKzr66Ic7Sl/D7Ky1cVUcFEzHjlq4fGDJwt6Lqg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/[email protected].1':
resolution: {integrity: sha512-oBdMp69ScC9UnniG4qZAQVr2fBDSXjm4yk/72vkEVyrlwS23yMxcRQl7Qdo3sgXPAygY6TWnPRKm4rXzjoyW7w==}
'@pnpm/[email protected].0':
resolution: {integrity: sha512-egl5RUSQFh7kETBdmMhaTuaQKP1pjH6Cxq370zgpFFKJXF+GGkIzK+jLNazrjX9pKIWF3iqn7kUeyP6yKxzBoA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@pnpm/[email protected].1':
resolution: {integrity: sha512-Yyb3wu09h3c7mLme270uHFiVaAIUHC411Ru9LIU2BKUIstrGlXa9OqqVWoXaRgYRdFdOFknkwG7GNn6ygwLbnw==}
'@pnpm/[email protected].0':
resolution: {integrity: sha512-YJ6bEZ+stPpv71CGfGQQeyIpMx/zSMEcAXQzG+rjBtzVfyNsxYciRevhJ+PE9b2qKqN78Mh/1tOMmx9ly4VXGw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@pnpm/[email protected].1':
resolution: {integrity: sha512-PdkeUFFkybknnGkWP0H22eMpIiS4OZXL5BcQzF4lh+vmrW3IaC2HOkbANKgot/PKO0Kl77pzA/6PKFxgbgfXIQ==}
'@pnpm/[email protected].0':
resolution: {integrity: sha512-njfd4euctC1zH1nVBbm6+r1c+0y/TtgqN523rk/KMSxqItpV5QAVa9Yz48Rt5DOIKmuLq7xXOhsQodBlbTk3pw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@pnpm/[email protected].1':
resolution: {integrity: sha512-1okKuHKJwJPAbofDxfhZWAUxEWtee4ZSf7Pah8kcvocaJkf2WxFJ5KVTiNIvDml5GLidDQsgf7Pcf/WHbCak7Q==}
'@pnpm/[email protected].0':
resolution: {integrity: sha512-4JI2F8pYGMcsG6ny61OiBSvZeDozc64TbPozbTU2X5uCsaFCsCvcpr/WwyypwCv+/mNjcIhaKhlxpwJYsnteIg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@pnpm/[email protected].1':
resolution: {integrity: sha512-e6IqzVNkPa7srFKJKnY23uWJ+qz0LaRWiTUo/sHfFXRRMRzB5uNOr6qWWip/jqhdGwU91n9J7LrEYZ6e0tRczw==}
'@pnpm/[email protected].0':
resolution: {integrity: sha512-xayd2iG8ugdvjlGeOvsfm+iSe0bFu9RTX3/8HWOLAQmHdbLKi4hPbxP0S4c9tj8pNej/IxVBQLJksRXFlhNjPw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/[email protected].1':
resolution: {integrity: sha512-ZByV0DRgWzGaDSjT5ddRBASdwz+r/qPMYh7o6RGmPQjtRGNFbSl94xY2DcAM8mqCOZJUUDtAXQ+wpBXcdpKlkg==}
'@pnpm/[email protected].0':
resolution: {integrity: sha512-PFkzWEghFLKuocxMHtn57mlJ59f5aBbqjX6FCUtiunEHqxTxOibSduQPs3Llm6VWbAXBjD3VrX29Z+bjU1Dpyw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@pnpm/[email protected].1':
resolution: {integrity: sha512-I7QZHcjioG7orFeCRUFUHByJ1G0zFPnAlaGGyrUvb6MGkv5oeNwNPl7t+Gp+7zHfnMcgLGJ1a+Wfim0IDw3lOw==}
'@pnpm/[email protected].0':
resolution: {integrity: sha512-0HtTS+TLONWd/dNSczyOkK0UeZ36cbdhC/2ljZ61DPhvc4jTb7ur0yU47On0in7Im3yXwzTY4vtF8ykrDwc+NQ==}
cpu: [arm64]
os: [win32]
'@pnpm/[email protected].1':
resolution: {integrity: sha512-4WJFdp58EKlCj0zLobrXU0vpszSZIr4X/3y/uNbAQqGW0zE+VdruZB0gFc3L2kzsUzVwTYbaCn+XEhnCYSAlTg==}
'@pnpm/[email protected].0':
resolution: {integrity: sha512-IcW3rehTGzJ7YnmHEWnbnJakjbBmnH0r29qiajK9+6b9psMF9IjocFr0dyitRVSfXxyW/FI5PIK3mOswGoyFTg==}
cpu: [x64]
os: [win32]
[email protected].1:
resolution: {integrity: sha512-ukCjfrGjcNYP6oxM8dNkwTvMzvUY6YrSvO6VT1OdONo3RY5YJho/pV4GUjRQ6g//+fSXsTW+QfLBXBZCusrObA==}
[email protected].0:
resolution: {integrity: sha512-Eip7bd+dQCaLwNsal4CJm5Xk+out/Ke9RQYVrYz3wmWfX1acIO18U4hrx7CNDVClT0rVBwaEf1aC7GxQSEFlOQ==}
engines: {node: '>=18.*'}
hasBin: true
snapshots:
'@pnpm/[email protected].1':
'@pnpm/[email protected].0':
optional: true
'@pnpm/[email protected].1':
'@pnpm/[email protected].0':
optional: true
'@pnpm/[email protected].1':
'@pnpm/[email protected].0':
optional: true
'@pnpm/[email protected].1':
'@pnpm/[email protected].0':
optional: true
'@pnpm/[email protected].1':
'@pnpm/[email protected].0':
optional: true
'@pnpm/[email protected].1':
'@pnpm/[email protected].0':
optional: true
'@pnpm/[email protected].1':
'@pnpm/[email protected].0':
optional: true
'@pnpm/[email protected].1':
'@pnpm/[email protected].0':
optional: true
'@pnpm/[email protected].1':
'@pnpm/[email protected].0':
optional: true
'@pnpm/[email protected].1':
'@pnpm/[email protected].0':
optional: true
'@pnpm/[email protected].1':
'@pnpm/[email protected].0':
optional: true
'@pnpm/[email protected].1':
'@pnpm/[email protected].0':
optional: true
'@pnpm/[email protected].1':
'@pnpm/[email protected].0':
optional: true
'@pnpm/[email protected].1':
'@pnpm/[email protected].0':
optional: true
[email protected].1:
[email protected].0:
optionalDependencies:
'@pnpm/exe.android-arm64': 12.10.1
'@pnpm/exe.android-x64': 12.10.1
'@pnpm/exe.darwin-arm64': 12.10.1
'@pnpm/exe.darwin-x64': 12.10.1
'@pnpm/exe.freebsd-x64': 12.10.1
'@pnpm/exe.linux-arm64': 12.10.1
'@pnpm/exe.linux-arm64-musl': 12.10.1
'@pnpm/exe.linux-ppc64': 12.10.1
'@pnpm/exe.linux-riscv64': 12.10.1
'@pnpm/exe.linux-s390x': 12.10.1
'@pnpm/exe.linux-x64': 12.10.1
'@pnpm/exe.linux-x64-musl': 12.10.1
'@pnpm/exe.win32-arm64': 12.10.1
'@pnpm/exe.win32-x64': 12.10.1
'@pnpm/exe.android-arm64': 12.10.0
'@pnpm/exe.android-x64': 12.10.0
'@pnpm/exe.darwin-arm64': 12.10.0
'@pnpm/exe.darwin-x64': 12.10.0
'@pnpm/exe.freebsd-x64': 12.10.0
'@pnpm/exe.linux-arm64': 12.10.0
'@pnpm/exe.linux-arm64-musl': 12.10.0
'@pnpm/exe.linux-ppc64': 12.10.0
'@pnpm/exe.linux-riscv64': 12.10.0
'@pnpm/exe.linux-s390x': 12.10.0
'@pnpm/exe.linux-x64': 12.10.0
'@pnpm/exe.linux-x64-musl': 12.10.0
'@pnpm/exe.win32-arm64': 12.10.0
'@pnpm/exe.win32-x64': 12.10.0
---
lockfileVersion: '9.0'
@@ -294,7 +294,7 @@ importers:
version: 1.0.3
'@maplibre/maplibre-gl-leaflet':
specifier: 0.1.4
version: 0.1.4(@types/[email protected])([email protected])([email protected]3.0)
version: 0.1.4(@types/[email protected])([email protected])([email protected]2.0)
'@material/mwc-list':
specifier: 0.27.0
version: 0.27.0(patch_hash=9450e509a86887d8f68ca9cf3f81e6ed70e94e49de13c5f6f20f94519ad4a987)
@@ -317,8 +317,8 @@ importers:
specifier: 4.4.0
version: 4.4.0
'@versatiles/style':
specifier: 6.3.0
version: 6.3.0([email protected]3.0)
specifier: 6.1.1
version: 6.1.1([email protected]2.0)
'@vibrant/color':
specifier: 4.0.4
version: 4.0.4
@@ -401,8 +401,8 @@ importers:
specifier: 3.7.2
version: 3.7.2
maplibre-gl:
specifier: 6.13.0
version: 6.13.0
specifier: 6.12.0
version: 6.12.0
marked:
specifier: 18.1.0
version: 18.1.0
@@ -2260,8 +2260,8 @@ packages:
'@mapbox/[email protected]':
resolution: {integrity: sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==}
'@maplibre/[email protected].2':
resolution: {integrity: sha512-2ZYhobdAY+y4/P/z1xe0698165LywD0yDdWugdu3NRUrajgVWut+tLkcz7i/RJguMTURwK7gxwmwYMFJbgbQFw==}
'@maplibre/[email protected].1':
resolution: {integrity: sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==}
'@maplibre/[email protected]':
resolution: {integrity: sha512-k57wcndZIvq3m08g2je4pjUPGr43ffNW3j+gXbymt8Vi2l1lUERUi1UmdTZcTPN8LuDDYOMWRNBaSLwpAu02ew==}
@@ -3693,8 +3693,8 @@ packages:
cpu: [x64]
os: [win32]
'@versatiles/style@6.3.0':
resolution: {integrity: sha512-jJvt1ur0JQqeuFLLyIk8bw/G8t5ix5oGcf1gvOT4XCJs3zi8ZJ5VsSptpghany/vnOw5l8+5f9OQCFcTreY/sQ==}
'@versatiles/style@6.1.1':
resolution: {integrity: sha512-yQJhAcSa7N2PO3QZqt1OhNHZ0njEmV5YXhlECqYJ+gwXppptFin5eoV6i/6CydpilMMn4I3WSMinB8499N6ClQ==}
engines: {node: '>=20'}
peerDependencies:
maplibre-gl: '>=5.0.0'
@@ -5911,8 +5911,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==}
[email protected]3.0:
resolution: {integrity: sha512-ELXk4h+xl0URFQEnVbAN6MGn61P6jq4amUWHiskLVsvHCaKdf1Zq6G8UNOD0AQ3UaXoL5ECc8ovz8yqYEBGPKw==}
[email protected]2.0:
resolution: {integrity: sha512-DwgganVi2BhNxOpD7ob3lJC0dQQz4HfJfuQQV3XxCY3XdOzFrMqp9ylMXhRzryRsOnIezdZcklWXIIs/Q8JPjA==}
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
[email protected]:
@@ -9780,15 +9780,15 @@ snapshots:
'@types/geojson': 7946.0.16
pbf: 5.1.2
'@maplibre/[email protected].2':
'@maplibre/[email protected].1':
dependencies:
kdbush: 4.1.0
'@maplibre/[email protected](@types/[email protected])([email protected])([email protected]3.0)':
'@maplibre/[email protected](@types/[email protected])([email protected])([email protected]2.0)':
dependencies:
'@types/leaflet': 1.9.22
leaflet: 1.9.4
maplibre-gl: 6.13.0
maplibre-gl: 6.12.0
'@maplibre/[email protected]':
dependencies:
@@ -11280,11 +11280,11 @@ snapshots:
'@unrs/[email protected]':
optional: true
'@versatiles/style@6.3.0([email protected]3.0)':
'@versatiles/style@6.1.1([email protected]2.0)':
dependencies:
'@maplibre/maplibre-gl-style-spec': 26.4.4
optionalDependencies:
maplibre-gl: 6.13.0
maplibre-gl: 6.12.0
'@vibrant/[email protected]': {}
@@ -13657,13 +13657,13 @@ snapshots:
[email protected]: {}
[email protected]3.0:
[email protected]2.0:
dependencies:
'@mapbox/point-geometry': 1.1.0
'@mapbox/tiny-sdf': 2.2.0
'@mapbox/unitbezier': 1.0.0
'@mapbox/vector-tile': 3.0.0
'@maplibre/geojson-vt': 6.1.2
'@maplibre/geojson-vt': 6.1.1
'@maplibre/maplibre-gl-style-spec': 26.4.4
'@maplibre/mlt': 1.3.0
'@maplibre/vt-pbf': 4.3.2
+3 -2
View File
@@ -249,10 +249,11 @@ export class HaSourcesPicker extends LitElement {
border-top: 1px solid var(--divider-color);
}
/* An expanded panel sizes itself to the space that is left over. */
/* An expanded panel sizes itself to the space that is left over, but
keeps room for its list when the targets fill the pane. */
.filters.expanded {
flex: 1 1 auto;
min-height: 0;
min-height: min(360px, 100%);
}
`;
}
+1 -7
View File
@@ -362,17 +362,11 @@ export type ConditionWithShorthand =
| ShorthandAndConditionList
| ShorthandAndCondition
| ShorthandOrCondition
| ShorthandNotCondition
| string;
| ShorthandNotCondition;
export const expandConditionWithShorthand = (
cond: ConditionWithShorthand
): Condition => {
// A bare template string is shorthand for a template condition
if (typeof cond === "string") {
return { condition: "template", value_template: cond };
}
if ("condition" in cond && Array.isArray(cond.condition)) {
return {
condition: "and",
@@ -277,7 +277,6 @@ class HuiAlarmPanelCard extends LitElement implements LovelaceCard {
@input=${this._handleInput}
.label=${this.hass.localize("ui.card.alarm_control_panel.code")}
type="password"
autocomplete="one-time-code"
.inputmode=${
stateObj.attributes.code_format === FORMAT_NUMBER
? "numeric"
+4 -5
View File
@@ -68,7 +68,7 @@ const TIMESTAMP_STATE_PROPS = ["last_updated", "last_changed"];
const TIMESTAMP_CONTENTS = [...TIMESTAMP_STATE_PROPS, "last_triggered"];
const TIMESTAMP_DOMAIN_CONTENTS: Record<string, string[] | undefined> = {
const TIMESTAMP_DOMAIN_CONTENTS = {
calendar: ["start_time", "end_time"],
input_datetime: ["timestamp"],
sun: [
@@ -106,11 +106,10 @@ export const stateContentHasTimestamp = (
}
}
}
const domainContents = TIMESTAMP_DOMAIN_CONTENTS[domain];
return (
!!content &&
!!domainContents &&
contentArray.some((c) => domainContents.includes(c))
TIMESTAMP_DOMAIN_CONTENTS[domain] &&
content &&
contentArray.some((c) => TIMESTAMP_DOMAIN_CONTENTS[domain].includes(c))
);
};
@@ -1,33 +0,0 @@
import { describe, expect, it } from "vitest";
import { expandConditionWithShorthand } from "../../src/data/automation";
describe("expandConditionWithShorthand", () => {
it("expands a template string to a template condition", () => {
expect(
expandConditionWithShorthand("{{ is_state('sun.sun', 'below_horizon') }}")
).toEqual({
condition: "template",
value_template: "{{ is_state('sun.sun', 'below_horizon') }}",
});
});
it("expands a condition list to an and condition", () => {
const conditions = [{ condition: "state", entity_id: "light.kitchen" }];
expect(
expandConditionWithShorthand({ condition: conditions } as any)
).toEqual({ condition: "and", conditions });
});
it("expands building block shorthand", () => {
const conditions = [{ condition: "state", entity_id: "light.kitchen" }];
expect(expandConditionWithShorthand({ or: conditions } as any)).toEqual({
condition: "or",
conditions,
});
});
it("returns a regular condition unchanged", () => {
const condition = { condition: "state", entity_id: "light.kitchen" } as any;
expect(expandConditionWithShorthand(condition)).toBe(condition);
});
});
-99
View File
@@ -1,99 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mockRecorder } from "../../demo/src/stubs/recorder";
import type { Statistics } from "../../src/data/recorder";
import type { MockHomeAssistant } from "../../src/fake_data/provide_hass";
const fetchWater = (
period: "5minute" | "hour" | "day" | "month",
ids = ["sensor.energy_water", "sensor.energy_water_cost"],
start = "2026-01-01T00:00:00Z",
end = "2026-02-01T00:00:00Z"
): Statistics => {
const mockWS = vi.fn();
const hass = { mockWS, states: {} } as unknown as MockHomeAssistant;
mockRecorder(hass);
const handler = mockWS.mock.calls.find(
([type]) => type === "recorder/statistics_during_period"
)![1];
return handler(
{
statistic_ids: ids,
start_time: start,
end_time: end,
period,
},
hass
);
};
describe("demo water statistics", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-01T00:00:00Z"));
vi.spyOn(Math, "random").mockReturnValue(0.5);
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it.each([
["5minute", 20 / 24],
["hour", 10],
["day", 240],
["month", 7440],
] as const)("scales consumption for %s buckets", (period, expected) => {
expect(fetchWater(period)["sensor.energy_water"][0].change).toBeCloseTo(
expected
);
});
it.each([
["2026-02-01", "2026-03-01", 6720],
["2024-02-01", "2024-03-01", 6960],
["2026-04-01", "2026-05-01", 7200],
])("uses the actual month duration from %s", (start, end, expected) => {
vi.setSystemTime(new Date("2027-01-01T00:00:00Z"));
const stats = fetchWater(
"month",
undefined,
`${start}T00:00:00Z`,
`${end}T00:00:00Z`
);
expect(stats["sensor.energy_water"][0].change).toBeCloseTo(expected);
});
it("scales each month independently in a multi-month request", () => {
vi.setSystemTime(new Date("2027-01-01T00:00:00Z"));
const stats = fetchWater(
"month",
undefined,
"2026-01-01T00:00:00Z",
"2026-04-01T00:00:00Z"
);
expect(stats["sensor.energy_water"].map((bucket) => bucket.change)).toEqual(
[7440, 6720, 7440]
);
});
it.each([
["sensor.energy_water", "sensor.energy_water_cost"],
["sensor.energy_water_cost", "sensor.energy_water"],
["sensor.energy_water_cost"],
])("derives costs regardless of requested IDs: %j", (...ids) => {
vi.spyOn(Math, "random").mockReturnValueOnce(0.25).mockReturnValue(0.5);
const stats = fetchWater("hour", ids);
const costs = stats["sensor.energy_water_cost"];
expect(costs[0].change).toBeCloseTo(5 * 0.004);
let total = 0;
costs.forEach((cost, index) => {
const consumption = stats["sensor.energy_water"]?.[index];
if (consumption) {
expect(cost.change).toBeCloseTo(consumption.change! * 0.004);
}
total += cost.change!;
expect(cost.sum).toBeCloseTo(total);
expect(cost.state).toBeCloseTo(total);
});
});
});
-89
View File
@@ -1,89 +0,0 @@
import { expect, type Locator, type Page } from "@playwright/test";
import type { AutomationConfig } from "../../../../src/data/automation";
import { PANEL_TIMEOUT, QUICK_TIMEOUT } from "../../helpers";
import { goToPanel } from "./helpers";
export const automationPicker = (page: Page) =>
page.locator("ha-automation-picker");
export const automationEditor = (page: Page) =>
page.locator("manual-automation-editor");
// Scoped to the dialog, the editor has a Save button of its own.
export const automationSaveDialog = (page: Page) =>
page.locator("ha-dialog-automation-save");
export async function openAutomationPicker(page: Page) {
await goToPanel(page, "/?scenario=automation-editor#/config/automation");
await expect(automationPicker(page)).toBeAttached({
timeout: PANEL_TIMEOUT,
});
}
export async function startNewAutomation(page: Page) {
await automationPicker(page)
.getByRole("button", { name: "Create automation" })
.click();
await page
.locator("ha-dialog-new-automation ha-list-item")
.filter({ hasText: "Create new automation" })
.click();
await expect(page).toHaveURL(/#\/config\/automation\/edit\/new$/);
await expect(automationEditor(page)).toBeAttached({
timeout: PANEL_TIMEOUT,
});
}
// Picks the element through the dialog's search, which is the same on wide
// and narrow layouts, unlike browsing its groups.
export async function addAutomationElement(
page: Page,
type: "trigger" | "action",
name: string
) {
await automationEditor(page)
.getByRole("button", { name: `Add ${type}`, exact: true })
.click();
const dialog = page.locator("add-automation-element-dialog");
await dialog.locator("ha-input-search input").fill(name);
await dialog
.locator("ha-automation-add-search ha-list-item-button")
.filter({ hasText: name })
.first()
.click();
await expect(dialog.locator("ha-input-search")).toBeHidden({
timeout: QUICK_TIMEOUT,
});
// The new element opens in the sidebar, which is a bottom sheet over the
// Save button on narrow layouts, so close it like a user would.
const sidebar = automationEditor(page).locator("ha-automation-sidebar");
await sidebar.getByRole("button", { name: "Close", exact: true }).click();
await expect(sidebar).toHaveClass(/hidden/);
}
// The host element, which carries the "dirty" class while there are unsaved
// changes.
export const saveAutomationButton = (page: Page) =>
automationEditor(page).locator('ha-button[slot="fab"]');
export const expectAutomationSaveDialog = async (
page: Page,
title: string
): Promise<Locator> => {
const dialog = automationSaveDialog(page);
await expect(dialog.getByRole("dialog", { name: title })).toBeVisible({
timeout: QUICK_TIMEOUT,
});
return dialog;
};
export const readSavedAutomation = (page: Page, id: string) =>
page.evaluate(
(automationId) =>
window.__mockHass.callApi<AutomationConfig>(
"GET",
`config/automation/config/${automationId}`
),
id
);
+1 -5
View File
@@ -18,10 +18,7 @@ import {
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEnergy } from "../../../../demo/src/stubs/energy";
import { energyEntities } from "../../../../demo/src/stubs/entities";
import {
mockEntityRegistry,
mockEntityRegistryDisplay,
} from "../../../../demo/src/stubs/entity_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockEvents } from "../../../../demo/src/stubs/events";
import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry";
import { mockFrontend } from "../../../../demo/src/stubs/frontend";
@@ -171,7 +168,6 @@ export class HaTest extends HomeAssistantAppEl {
mockDeviceRegistry(hass);
mockFloorRegistry(hass);
mockLabelRegistry(hass);
mockEntityRegistryDisplay(hass);
mockEntityRegistry(hass, []);
mockConfigEntries(hass);
mockIcons(hass);
-15
View File
@@ -372,20 +372,6 @@ const delayedMediaBrowseErrorScenario: Scenario = (hass) => {
hass.mockWS("media_source/browse_media", () => browsePromise);
};
const automationEditorScenario: Scenario = async (hass) => {
// A direct load of an automation route otherwise races the config panel
// translations that the editor's buttons and dialogs are named by.
await hass.loadFragmentTranslation("config");
// Like a default install, so creating an automation starts in the dialog
// that also offers blueprints.
hass.updateHass({
config: {
...hass.config,
components: [...hass.config.components, "blueprint"],
},
});
};
const systemLogReportingScenario: Scenario = async (hass) => {
await hass.loadFragmentTranslation("config");
hass.updateHass({
@@ -511,7 +497,6 @@ const systemLogReportingScenario: Scenario = async (hass) => {
export const scenarios: Record<string, Scenario> = {
default: defaultScenario,
"automation-editor": automationEditorScenario,
"non-admin": nonAdminScenario,
"dark-theme": darkThemeScenario,
"custom-theme": customThemeScenario,
-125
View File
@@ -1,125 +0,0 @@
/**
* E2E tests for creating automations in the HA test app (port 8095).
*
* Run with:
* pnpm test:e2e:app -g "Automation editor"
*/
import { expect, test } from "@playwright/test";
import {
addAutomationElement,
automationEditor,
automationPicker,
automationSaveDialog,
expectAutomationSaveDialog,
openAutomationPicker,
readSavedAutomation,
saveAutomationButton,
startNewAutomation,
} from "./app/src/automation";
import {
expectNoPageErrors,
PANEL_TIMEOUT,
QUICK_TIMEOUT,
trackPageErrors,
} from "./helpers";
test.describe("Automation editor", () => {
let errors: ReturnType<typeof trackPageErrors>;
test.beforeEach(async ({ page }) => {
errors = trackPageErrors(page);
await openAutomationPicker(page);
await startNewAutomation(page);
});
test.afterEach(() => {
expectNoPageErrors(errors);
});
test("creates an automation through the save dialog", async ({ page }) => {
await addAutomationElement(page, "trigger", "Home Assistant");
await addAutomationElement(page, "action", "Wait for time to pass (delay)");
await saveAutomationButton(page).click();
const dialog = await expectAutomationSaveDialog(page, "Save");
const name = dialog.getByRole("textbox", { name: "Name" });
await expect(name).toHaveValue("New automation");
await name.fill("E2E test automation");
await dialog.getByRole("button", { name: "Add description" }).click();
await dialog
.getByRole("textbox", { name: "Description" })
.fill("Created by Playwright");
await dialog.getByRole("button", { name: "Save", exact: true }).click();
await expect(page).toHaveURL(/#\/config\/automation\/edit\/\d+$/, {
timeout: PANEL_TIMEOUT,
});
const id = page.url().split("/").pop()!;
await expect(dialog.getByRole("dialog")).toBeHidden();
// Shown when the new automation never reaches the entity registry.
await expect(
page.locator("dialog-automation-save-timeout")
).not.toBeAttached();
await expect(page.locator("hass-subpage .main-title")).toHaveText(
"E2E test automation"
);
await expect(saveAutomationButton(page)).not.toHaveClass(/dirty/);
expect(await readSavedAutomation(page, id)).toEqual({
id,
alias: "E2E test automation",
description: "Created by Playwright",
mode: "single",
triggers: [{ trigger: "homeassistant", event: "start" }],
conditions: [],
actions: [{ delay: expect.anything() }],
});
await page.goBack();
await expect(
automationPicker(page).getByRole("row", { name: /E2E test automation/ })
).toBeVisible({ timeout: PANEL_TIMEOUT });
});
test("keeps the save dialog open without a name", async ({ page }) => {
await addAutomationElement(page, "trigger", "Home Assistant");
await saveAutomationButton(page).click();
const dialog = await expectAutomationSaveDialog(page, "Save");
await dialog.getByRole("textbox", { name: "Name" }).clear();
await dialog.getByRole("button", { name: "Save", exact: true }).click();
await expect(
dialog.getByText("Cannot save automation without a name")
).toBeVisible({ timeout: QUICK_TIMEOUT });
await expect(dialog.getByRole("dialog")).toBeVisible();
await dialog.getByRole("button", { name: "Cancel" }).click();
await expect(dialog.getByRole("dialog")).toBeHidden();
await expect(page).toHaveURL(/#\/config\/automation\/edit\/new$/);
await expect(saveAutomationButton(page)).toHaveClass(/dirty/);
});
test("discards a new automation when leaving without saving", async ({
page,
}) => {
await addAutomationElement(page, "trigger", "Home Assistant");
await page.locator("hass-subpage ha-icon-button-arrow-prev").click();
const dialog = await expectAutomationSaveDialog(
page,
"Save new automation?"
);
await dialog.getByRole("button", { name: "Don't save" }).click();
await expect(page).toHaveURL(/#\/config\/automation\/dashboard$/, {
timeout: PANEL_TIMEOUT,
});
// The harness has no automations, so the picker is still empty.
await expect(
automationPicker(page).getByRole("heading", { name: "Start automating" })
).toBeVisible();
await expect(automationEditor(page)).not.toBeAttached();
await expect(automationSaveDialog(page).getByRole("dialog")).toBeHidden();
});
});
+1 -1
View File
@@ -6,7 +6,7 @@ const APP_BASE_URL = `http://localhost:${APP_PORT}`;
export default defineConfig({
testDir: ".",
testMatch: ["app.spec.ts", "automation.spec.ts", "onboarding.spec.ts"],
testMatch: ["app.spec.ts", "onboarding.spec.ts"],
timeout: 60_000,
expect: { timeout: 15_000 },