Files
frontend/test/data/energy.test.ts
T
Petar Petrov 19cd06bb8c Fix energy period rollover overnight and in the first hour (#53717)
* Fix energy dashboard staying on yesterday after midnight.

* Catch up the energy live day before subscribe fetches.

* Keep energy day math in the server timezone so DST cannot skip a live day.

Browser-local addDays can jump a calendar day on a 23-hour DST fallback.
Assert against tz-internal endOfDay/addDays under Europe/Berlin so UTC CI
catches a regression, and prove the 01:00 timer and catch-up refresh fetch
the live day rather than only updating collection.start.

* Don't follow the live day from midnightRollover alone.

A stored non-today preset would otherwise be discarded on the first subscribe. Drop tautological UTC DST tests that cannot fail.
2026-08-24 10:16:00 +02:00

1490 lines
38 KiB
TypeScript

import { addDays, endOfDay, startOfDay } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket";
import { afterEach, assert, describe, it, vi } from "vitest";
import { calcDate } from "../../src/common/datetime/calc_date";
import {
type FrontendLocaleData,
NumberFormat,
TimeFormat,
FirstWeekday,
DateFormat,
TimeZone,
} from "../../src/data/translation";
import {
computeConsumptionSingle,
computeEnergyLabel,
computeEnergyDeviceLabels,
formatConsumptionShort,
calculateSolarConsumedGauge,
formatPowerShort,
getNextEnergyPeriodStart,
getEnergyDefaultPeriodStorageKey,
getEnergyFirstStatisticAt,
getEnergyLiveDayPeriod,
shouldFallbackEnergyPeriodToYesterday,
getEnergyDataCollection,
EMPTY_PREFERENCES,
} from "../../src/data/energy";
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../src/data/entity/entity_registry";
import type { StatisticsMetaData } from "../../src/data/recorder";
import type { HomeAssistant } from "../../src/types";
import { createMockEntityState, createMockHass } from "../fixtures/hass";
const checkConsumptionResult = (
input: {
from_grid: number | undefined;
to_grid: number | undefined;
solar: number | undefined;
to_battery: number | undefined;
from_battery: number | undefined;
},
exact = true
): {
grid_to_battery: number;
battery_to_grid: number;
solar_to_battery: number;
solar_to_grid: number;
used_solar: number;
used_grid: number;
used_battery: number;
used_total: number;
} => {
const result = computeConsumptionSingle(input);
if (exact) {
assert.equal(
result.used_total,
result.used_solar + result.used_battery + result.used_grid
);
assert.equal(
input.to_grid || 0,
result.solar_to_grid + result.battery_to_grid
);
assert.equal(
input.to_battery || 0,
result.grid_to_battery + result.solar_to_battery
);
assert.equal(
input.solar || 0,
result.solar_to_battery + result.solar_to_grid + result.used_solar
);
}
return result;
};
describe("Energy Short Format Test", () => {
// Create default to not have to specify a not relevant TimeFormat over and over again.
const defaultLocale: FrontendLocaleData = {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.local,
first_weekday: FirstWeekday.language,
};
const hass = { locale: defaultLocale } as HomeAssistant;
it("No Unit conversion", () => {
assert.strictEqual(formatConsumptionShort(hass, 0, "Wh"), "0 Wh");
assert.strictEqual(formatConsumptionShort(hass, 0, "kWh"), "0 Wh");
assert.strictEqual(formatConsumptionShort(hass, 0, "kWh", "kWh"), "0 kWh");
assert.strictEqual(formatConsumptionShort(hass, 0, "GWh"), "0 Wh");
assert.strictEqual(formatConsumptionShort(hass, 0, "GWh", "GWh"), "0 GWh");
assert.strictEqual(formatConsumptionShort(hass, 0, "gal"), "0 gal");
assert.strictEqual(
formatConsumptionShort(hass, 10000.12345, "gal"),
"10,000 gal"
);
assert.strictEqual(formatConsumptionShort(hass, 1.2345, "kWh"), "1.23 kWh");
assert.strictEqual(
formatConsumptionShort(hass, 10.12345, "kWh"),
"10.1 kWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 500.12345, "kWh"),
"500 kWh"
);
assert.strictEqual(formatConsumptionShort(hass, 10.01, "kWh"), "10 kWh");
});
it("Upward Unit conversion", () => {
assert.strictEqual(
formatConsumptionShort(hass, 1512.34567, "kWh"),
"1.51 MWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 15123.4567, "kWh"),
"15.1 MWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 151234.5678, "kWh"),
"151 MWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 1512345.6789, "kWh"),
"1.51 GWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 15123456789.9, "kWh"),
"15.1 TWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 15123456789000.9, "kWh"),
"15,123 TWh"
);
});
it("Downward Unit conversion", () => {
assert.strictEqual(formatConsumptionShort(hass, 0.00012, "kWh"), "0.12 Wh");
assert.strictEqual(formatConsumptionShort(hass, 0.12345, "kWh"), "123 Wh");
assert.strictEqual(
formatConsumptionShort(hass, 0.00001234, "TWh"),
"12.3 MWh"
);
});
it("Negativ Consumption", () => {
assert.strictEqual(
formatConsumptionShort(hass, -500.123, "kWh"),
"-500 kWh"
);
assert.strictEqual(
formatConsumptionShort(hass, -1234.56, "kWh"),
"-1.23 MWh"
);
assert.strictEqual(
formatConsumptionShort(hass, -0.001234, "kWh"),
"-1.23 Wh"
);
});
it("Conversion with target unit", () => {
assert.strictEqual(
formatConsumptionShort(hass, 0.00012, "kWh", "Wh"),
"0.12 Wh"
);
assert.strictEqual(
formatConsumptionShort(hass, 0.00012, "kWh", "kWh"),
"0 kWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 0.01012, "kWh", "kWh"),
"0.01 kWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 0.00012, "kWh", "MWh"),
"0 MWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 10.12345, "kWh", "kWh"),
"10.1 kWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 10.12345, "kWh", "ZZZZZWh"),
"10.1 kWh"
);
assert.strictEqual(
formatConsumptionShort(hass, 151234.5678, "kWh", "MWh"),
"151 MWh"
);
});
it("Power Short Format", () => {
assert.strictEqual(formatPowerShort(hass, 0), "0 W");
assert.strictEqual(formatPowerShort(hass, 10), "10 W");
assert.strictEqual(formatPowerShort(hass, 12.2), "12 W");
assert.strictEqual(formatPowerShort(hass, 999), "999 W");
assert.strictEqual(formatPowerShort(hass, 1000), "1 kW");
assert.strictEqual(formatPowerShort(hass, 1234), "1.234 kW");
assert.strictEqual(formatPowerShort(hass, 10_500), "10.5 kW");
assert.strictEqual(formatPowerShort(hass, 1_500_000), "1.5 MW");
assert.strictEqual(formatPowerShort(hass, -1500), "-1.5 kW");
});
});
describe("Energy Usage Calculation Tests", () => {
it("Consuming Energy From the Grid", () => {
[0, 5, 1000].forEach((x) => {
assert.deepEqual(
checkConsumptionResult({
from_grid: x,
to_grid: undefined,
solar: undefined,
to_battery: undefined,
from_battery: undefined,
}),
{
grid_to_battery: 0,
battery_to_grid: 0,
used_solar: 0,
used_grid: x,
used_battery: 0,
used_total: x,
solar_to_battery: 0,
solar_to_grid: 0,
}
);
});
});
it("Solar production, consuming some and returning the remainder to grid.", () => {
(
[
[2.99, false], // unsolveable : solar < to_grid
[3, true],
[10, true],
[100, true],
] as any
).forEach(([s, exact]) => {
assert.deepEqual(
checkConsumptionResult(
{
from_grid: 0,
to_grid: 3,
solar: s,
to_battery: undefined,
from_battery: undefined,
},
exact
),
{
grid_to_battery: 0,
battery_to_grid: 0,
used_solar: Math.min(s, Math.max(0, s - 3)),
used_grid: 0,
used_battery: 0,
used_total: s - 3,
solar_to_battery: 0,
solar_to_grid: Math.min(3, s),
}
);
});
});
it("Solar production with simultaneous grid consumption. Excess solar returned to the grid.", () => {
(
[
[0, 0, true],
[3, 0, true],
[0, 3, true],
[5, 4, true],
[4, 5, true],
[10, 3, true],
[3, 7, true],
[3, 7.1, false], // unsolveable: to_grid > solar
] as any
).forEach(([from_grid, to_grid, exact]) => {
assert.deepEqual(
checkConsumptionResult(
{
from_grid,
to_grid,
solar: 7,
to_battery: undefined,
from_battery: undefined,
},
exact
),
{
grid_to_battery: 0,
battery_to_grid: 0,
used_solar: Math.max(0, 7 - to_grid),
used_grid: from_grid - Math.max(0, to_grid - 7),
used_total: from_grid - to_grid + 7,
used_battery: 0,
solar_to_battery: 0,
solar_to_grid: Math.min(7, to_grid),
}
);
});
});
it("Charging the battery from the grid", () => {
assert.deepEqual(
checkConsumptionResult({
from_grid: 5,
to_grid: 0,
solar: 0,
to_battery: 3,
from_battery: 0,
}),
{
grid_to_battery: 3,
battery_to_grid: 0,
used_solar: 0,
used_grid: 2,
used_battery: 0,
used_total: 2,
solar_to_battery: 0,
solar_to_grid: 0,
}
);
});
it("Consuming from the grid and battery simultaneously", () => {
assert.deepEqual(
checkConsumptionResult({
from_grid: 5,
to_grid: 0,
solar: 0,
to_battery: 0,
from_battery: 5,
}),
{
grid_to_battery: 0,
battery_to_grid: 0,
used_solar: 0,
used_grid: 5,
used_battery: 5,
used_total: 10,
solar_to_battery: 0,
solar_to_grid: 0,
}
);
});
it("Consuming some battery and returning some battery to the grid", () => {
assert.deepEqual(
checkConsumptionResult({
from_grid: 0,
to_grid: 4,
solar: 0,
to_battery: 0,
from_battery: 5,
}),
{
grid_to_battery: 0,
battery_to_grid: 4,
used_solar: 0,
used_grid: 0,
used_battery: 1,
used_total: 1,
solar_to_battery: 0,
solar_to_grid: 0,
}
);
});
it("Charging and discharging the battery to/from the grid in the same interval.", () => {
assert.deepEqual(
checkConsumptionResult({
from_grid: 5,
to_grid: 1,
solar: 0,
to_battery: 3,
from_battery: 1,
}),
{
grid_to_battery: 3,
battery_to_grid: 1,
used_solar: 0,
used_grid: 2,
used_battery: 0,
used_total: 2,
solar_to_battery: 0,
solar_to_grid: 0,
}
);
});
it("Charging the battery with no solar sensor.", () => {
assert.deepEqual(
checkConsumptionResult({
from_grid: 5,
to_grid: 0,
solar: undefined,
to_battery: 3,
from_battery: 0,
}),
{
grid_to_battery: 3,
battery_to_grid: 0,
used_solar: 0,
used_grid: 2,
used_battery: 0,
used_total: 2,
solar_to_battery: 0,
solar_to_grid: 0,
}
);
});
it("Discharging battery to grid while also consuming from grid.", () => {
assert.deepEqual(
checkConsumptionResult({
from_grid: 5,
to_grid: 4,
solar: 0,
to_battery: 0,
from_battery: 4,
}),
{
grid_to_battery: 0,
battery_to_grid: 4,
used_solar: 0,
used_grid: 5,
used_battery: 0,
used_total: 5,
solar_to_grid: 0,
solar_to_battery: 0,
}
);
});
it("Grid, solar, and battery", () => {
assert.deepEqual(
checkConsumptionResult({
from_grid: 5,
to_grid: 3,
solar: 7,
to_battery: 3,
from_battery: 0,
}),
{
grid_to_battery: 0,
battery_to_grid: 0,
used_solar: 1,
used_grid: 5,
used_battery: 0,
used_total: 6,
solar_to_battery: 3,
solar_to_grid: 3,
}
);
assert.deepEqual(
checkConsumptionResult({
from_grid: 5,
to_grid: 3,
solar: 7,
to_battery: 3,
from_battery: 10,
}),
{
grid_to_battery: 0,
battery_to_grid: 0,
used_solar: 1,
used_grid: 5,
used_battery: 10,
used_total: 16,
solar_to_battery: 3,
solar_to_grid: 3,
}
);
assert.deepEqual(
checkConsumptionResult({
from_grid: 2,
to_grid: 7,
solar: 7,
to_battery: 1,
from_battery: 1,
}),
{
grid_to_battery: 0,
battery_to_grid: 1,
used_solar: 0,
used_grid: 2,
used_battery: 0,
used_total: 2,
solar_to_battery: 1,
solar_to_grid: 6,
}
);
assert.deepEqual(
checkConsumptionResult({
from_grid: 2,
to_grid: 7,
solar: 9,
to_battery: 1,
from_battery: 1,
}),
{
grid_to_battery: 0,
battery_to_grid: 0,
used_solar: 1,
used_grid: 2,
used_battery: 1,
used_total: 4,
solar_to_battery: 1,
solar_to_grid: 7,
}
);
assert.deepEqual(
checkConsumptionResult({
from_grid: 5,
to_grid: 3,
solar: 1,
to_battery: 0,
from_battery: 2,
}),
{
grid_to_battery: 0,
battery_to_grid: 2,
used_solar: 0,
used_grid: 5,
used_battery: 0,
used_total: 5,
solar_to_battery: 0,
solar_to_grid: 1,
}
);
assert.deepEqual(
checkConsumptionResult({
from_grid: 6,
to_grid: 0,
solar: 3,
to_battery: 6,
from_battery: 6,
}),
{
grid_to_battery: 3,
battery_to_grid: 0,
used_solar: 0,
used_grid: 3,
used_battery: 6,
solar_to_battery: 3,
solar_to_grid: 0,
used_total: 9,
}
);
});
it("Solar -> Battery -> Grid", () => {
assert.deepEqual(
checkConsumptionResult({
from_grid: 0,
to_grid: 1,
solar: 1,
to_battery: 1,
from_battery: 1,
}),
{
grid_to_battery: 0,
battery_to_grid: 1,
used_solar: 0,
used_grid: 0,
used_battery: 0,
solar_to_battery: 1,
solar_to_grid: 0,
used_total: 0,
}
);
});
it("Solar -> Grid && Grid -> Battery", () => {
assert.deepEqual(
checkConsumptionResult({
from_grid: 1,
to_grid: 1,
solar: 1,
to_battery: 1,
from_battery: 0,
}),
{
grid_to_battery: 1,
battery_to_grid: 0,
used_solar: 0,
used_grid: 0,
used_battery: 0,
solar_to_battery: 0,
solar_to_grid: 1,
used_total: 0,
}
);
});
it("bug #25387", () => {
assert.deepEqual(
checkConsumptionResult(
{
from_grid: 0.059,
to_grid: 48.0259,
solar: 61.22,
to_battery: 5.716,
from_battery: 4.83,
},
false
),
{
grid_to_battery: 0,
battery_to_grid: 0,
used_solar: 7.478099999999998,
used_grid: 0.05899999999999572,
used_battery: 4.83,
solar_to_battery: 5.716,
solar_to_grid: 48.0259,
used_total: 12.367099999999994,
}
);
});
});
describe("Self-consumed solar gauge tests", () => {
it("no battery", () => {
const hasBattery = false;
assert.deepEqual(
calculateSolarConsumedGauge(hasBattery, {
total: {},
timestamps: [0],
}),
undefined
);
assert.deepEqual(
calculateSolarConsumedGauge(hasBattery, {
solar: {
"0": 0,
},
total: {
solar: 0,
},
timestamps: [0],
}),
undefined
);
assert.deepEqual(
calculateSolarConsumedGauge(hasBattery, {
solar: {
"0": 1,
"1": 3,
},
total: {
solar: 4,
},
timestamps: [0, 1],
}),
100
);
assert.deepEqual(
calculateSolarConsumedGauge(hasBattery, {
solar: {
"0": 1,
"1": 3,
},
to_grid: {
"1": 1,
},
total: {
solar: 4,
to_grid: 1,
},
timestamps: [0, 1],
}),
75
);
assert.deepEqual(
calculateSolarConsumedGauge(hasBattery, {
solar: {
"0": 1,
"1": 3,
},
to_grid: {
"0": 1,
"1": 3,
},
total: {
solar: 4,
to_grid: 4,
},
timestamps: [0, 1],
}),
0
);
});
it("with battery", () => {
const hasBattery = true;
assert.deepEqual(
calculateSolarConsumedGauge(hasBattery, {
total: {},
timestamps: [0],
}),
undefined
);
assert.deepEqual(
calculateSolarConsumedGauge(hasBattery, {
solar: {
"0": 0,
},
total: {
solar: 0,
},
timestamps: [0],
}),
undefined
);
assert.deepEqual(
calculateSolarConsumedGauge(hasBattery, {
solar: {
"0": 1,
"1": 3,
},
total: {
solar: 4,
},
timestamps: [0, 1],
}),
100
);
assert.deepEqual(
calculateSolarConsumedGauge(hasBattery, {
solar: {
"0": 1,
"1": 3,
},
to_grid: {
"1": 1,
},
total: {
solar: 4,
},
timestamps: [0, 1],
}),
75
);
assert.deepEqual(
calculateSolarConsumedGauge(hasBattery, {
solar: {
"10": 1,
},
to_grid: {
"0": 1,
"1": 1,
"2": 1,
"3": 1,
},
from_battery: {
"0": 1,
"1": 1,
"2": 1,
"3": 1,
},
total: {
solar: 1,
},
timestamps: [0, 1, 2, 3, 10],
}),
// As the battery is discharged from unknown source, it does not affect solar production number.
100
);
assert.deepEqual(
calculateSolarConsumedGauge(hasBattery, {
solar: {
"0": 10,
},
to_battery: {
"0": 10,
},
to_grid: {
"1": 3,
"3": 1,
},
from_battery: {
"1": 3,
"2": 2,
"3": 2,
"4": 3,
"5": 100, // Unknown source, not counted
},
total: {
solar: 10,
},
timestamps: [0, 1, 2, 3, 4, 5],
}),
// As the battery is discharged from unknown source, it does not affect solar production number.
60
);
});
it("complex battery/solar/grid", () => {
const hasBattery = true;
const value = calculateSolarConsumedGauge(hasBattery, {
solar: {
"1": 6,
"2": 0,
"3": 7,
},
to_battery: {
"1": 5,
"2": 5,
"3": 7,
},
to_grid: {
"0": 5,
"10": 1,
"11": 1,
"12": 5,
"13": 3,
},
from_grid: {
"2": 5,
},
from_battery: {
"0": 5,
"10": 3,
"11": 4,
"12": 5,
"13": 5,
},
total: {
// Total is mostly don't care when hasBattery, only hourly values are used
solar: 13,
},
timestamps: [0, 1, 2, 3, 10, 11, 12, 13],
});
// "1" - consumed 1 solar, 5 sent to battery
// "10" - consumed 2/3 of solar energy stored in battery
// "11" - consumed 3/4 of solar energy stored in battery
// "12" - skipped as this is energy from grid, not counted
// "13" - consumed 2/5 of solar energy stored in battery
const expectedNumerator = 1 + 2 + 3 + 0 + 2; // 8
const expectedDenominator = 1 + 3 + 4 + 0 + 5; // 13
assert.equal(
Math.round(value!),
Math.round((expectedNumerator / expectedDenominator) * 100)
);
});
it("complex battery/solar/grid #2", () => {
const hasBattery = true;
const value = calculateSolarConsumedGauge(hasBattery, {
solar: {
"0": 100,
"2": 100,
},
to_battery: {
"0": 100,
"1": 100,
"2": 100,
},
to_grid: {
"10": 50,
},
from_grid: {
"1": 100,
},
from_battery: {
"10": 300,
},
total: {
solar: 200,
to_battery: 300,
to_grid: 50,
from_grid: 100,
from_battery: 300,
},
timestamps: [0, 1, 2, 10],
});
const expectedNumerator = 200 - 50;
const expectedDenominator = 200; // ignoring 100 from grid
assert.equal(
Math.round(value!),
Math.round((expectedNumerator / expectedDenominator) * 100)
);
});
});
// Pin the time zone (via TimeZone.server) so energy period tests do not
// depend on the machine's local zone.
const energyPeriodLocale: FrontendLocaleData = {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.server,
first_weekday: FirstWeekday.language,
};
const energyPeriodConfig = { time_zone: "America/New_York" } as HassConfig;
const energyPeriodDay = (now: Date, offset = 0) => {
const day = calcDate(
now,
addDays,
energyPeriodLocale,
energyPeriodConfig,
offset
);
return {
start: calcDate(day, startOfDay, energyPeriodLocale, energyPeriodConfig),
end: calcDate(day, endOfDay, energyPeriodLocale, energyPeriodConfig),
};
};
describe("getNextEnergyPeriodStart", () => {
const isMidnight = (date: Date) =>
calcDate(
date,
startOfDay,
energyPeriodLocale,
energyPeriodConfig
).getTime() === date.getTime();
it("rolls the real-time view over at midnight, statistics an hour later", () => {
const now = new Date("2026-06-19T15:30:00-04:00");
const realTime = getNextEnergyPeriodStart(
true,
now,
energyPeriodLocale,
energyPeriodConfig
);
const statistics = getNextEnergyPeriodStart(
false,
now,
energyPeriodLocale,
energyPeriodConfig
);
// Real-time rolls over exactly at the next midnight.
assert.isTrue(isMidnight(realTime));
assert.equal(
realTime.getTime(),
new Date("2026-06-20T00:00:00-04:00").getTime()
);
// Statistics roll over an hour after midnight, on the same day boundary.
assert.equal(statistics.getTime() - realTime.getTime(), 60 * 60 * 1000);
assert.equal(
calcDate(
statistics,
startOfDay,
energyPeriodLocale,
energyPeriodConfig
).getTime(),
realTime.getTime()
);
});
it("advances the real-time view to the next midnight when called after midnight", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const realTime = getNextEnergyPeriodStart(
true,
now,
energyPeriodLocale,
energyPeriodConfig
);
assert.isTrue(isMidnight(realTime));
// Next midnight is June 21, not the already-passed June 20 midnight.
assert.equal(
realTime.getTime(),
new Date("2026-06-21T00:00:00-04:00").getTime()
);
});
it("wakes a non-today live day at today 01:00 during hour 0", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const todayOne = new Date("2026-06-20T01:00:00-04:00").getTime();
for (const offset of [-1, -2]) {
assert.equal(
getNextEnergyPeriodStart(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now, offset).start
).getTime(),
todayOne
);
}
});
it("keeps tomorrow 01:00 when statistics is already on today during hour 0", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
assert.equal(
getNextEnergyPeriodStart(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now).start
).getTime(),
new Date("2026-06-21T01:00:00-04:00").getTime()
);
});
});
describe("shouldFallbackEnergyPeriodToYesterday", () => {
it("is true for the statistics view before 01:00", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
assert.isTrue(
shouldFallbackEnergyPeriodToYesterday(
false,
now,
energyPeriodLocale,
energyPeriodConfig
)
);
assert.equal(
getEnergyFirstStatisticAt(
now,
energyPeriodLocale,
energyPeriodConfig
).getTime(),
new Date("2026-06-20T01:00:00-04:00").getTime()
);
});
it("is false at 01:00 and for the real-time view", () => {
const atOne = new Date("2026-06-20T01:00:00-04:00");
const beforeOne = new Date("2026-06-20T00:30:00-04:00");
assert.isFalse(
shouldFallbackEnergyPeriodToYesterday(
false,
atOne,
energyPeriodLocale,
energyPeriodConfig
)
);
assert.isFalse(
shouldFallbackEnergyPeriodToYesterday(
true,
beforeOne,
energyPeriodLocale,
energyPeriodConfig
)
);
});
});
describe("getEnergyLiveDayPeriod", () => {
it("keeps yesterday during hour 0 when that is the current period", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const { start, end } = energyPeriodDay(now, -1);
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
start
);
assert.equal(live.start.getTime(), start.getTime());
assert.equal(live.end.getTime(), end.getTime());
});
it("keeps today during hour 0 when the user already picked today", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const { start, end } = energyPeriodDay(now);
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
start
);
assert.equal(live.start.getTime(), start.getTime());
assert.equal(live.end.getTime(), end.getTime());
});
it("advances a stale yesterday to today after 01:00", () => {
const now = new Date("2026-06-20T10:00:00-04:00");
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now, -1).start
);
const expected = energyPeriodDay(now);
assert.equal(live.start.getTime(), expected.start.getTime());
assert.equal(live.end.getTime(), expected.end.getTime());
});
it("advances a two-day-old live day to today", () => {
const now = new Date("2026-06-20T10:00:00-04:00");
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now, -2).start
);
const expected = energyPeriodDay(now);
assert.equal(live.start.getTime(), expected.start.getTime());
assert.equal(live.end.getTime(), expected.end.getTime());
});
it("falls back to yesterday for a stale live day during hour 0", () => {
const now = new Date("2026-06-20T00:30:00-04:00");
const live = getEnergyLiveDayPeriod(
false,
now,
energyPeriodLocale,
energyPeriodConfig,
energyPeriodDay(now, -2).start
);
const expected = energyPeriodDay(now, -1);
assert.equal(live.start.getTime(), expected.start.getTime());
assert.equal(live.end.getTime(), expected.end.getTime());
});
});
describe("getEnergyDataCollection live day", () => {
afterEach(() => {
localStorage.clear();
vi.useRealTimers();
});
const createCollection = (
key: string,
preset?: string,
midnightRollover = false
) => {
const hass = createMockHass();
hass.locale = energyPeriodLocale;
hass.config = { ...hass.config, time_zone: "America/New_York" };
const callWS = vi.fn(async (msg: { type: string }) => {
if (msg.type === "energy/info") {
return { cost_sensors: {}, solar_forecast_domains: [] };
}
throw new Error(`unexpected ${msg.type}`);
});
Object.assign(hass, {
connection: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
connected: true,
},
callWS,
});
if (preset) {
localStorage.setItem(getEnergyDefaultPeriodStorageKey(hass, key), preset);
}
return {
collection: getEnergyDataCollection(hass, {
key,
prefs: EMPTY_PREFERENCES,
midnightRollover,
}),
callWS,
};
};
const energyInfoFetches = (callWS: ReturnType<typeof vi.fn>) =>
callWS.mock.calls.filter((call) => call[0].type === "energy/info");
it("advances hour-0 yesterday to today at 01:00 and fetches the new day", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-20T00:30:00-04:00"));
const { collection, callWS } = createCollection("energy_timer");
const refresh = vi.spyOn(collection, "refresh");
const unsub = collection.subscribe(() => undefined);
await vi.advanceTimersByTimeAsync(0);
refresh.mockClear();
callWS.mockClear();
assert.equal(
collection.start.getTime(),
energyPeriodDay(new Date(), -1).start.getTime()
);
await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
assert.equal(
collection.start.getTime(),
energyPeriodDay(new Date()).start.getTime()
);
// Cards render EnergyData from the websocket store, not collection.start.
// The 01:00 callback must refresh() so getEnergyData runs for today.
assert.equal(refresh.mock.calls.length, 1);
assert.equal(energyInfoFetches(callWS).length, 1);
unsub();
});
it("catches up a stale live day on resubscribe", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
const { collection, callWS } = createCollection("energy_catchup");
const refresh = vi.spyOn(collection, "refresh");
let unsub = collection.subscribe(() => undefined);
await vi.advanceTimersByTimeAsync(0);
refresh.mockClear();
callWS.mockClear();
unsub();
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
unsub = collection.subscribe(() => undefined);
await vi.advanceTimersByTimeAsync(0);
assert.equal(
collection.start.getTime(),
energyPeriodDay(new Date()).start.getTime()
);
assert.equal(refresh.mock.calls.length, 1);
assert.equal(energyInfoFetches(callWS).length, 1);
unsub();
});
it("does not double-refresh on a cold subscribe after the unsub grace", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
const { collection, callWS } = createCollection("energy_cold_refresh");
const refresh = vi.spyOn(collection, "refresh");
let unsub = collection.subscribe(() => undefined);
await vi.advanceTimersByTimeAsync(0);
unsub();
await vi.advanceTimersByTimeAsync(5000);
refresh.mockClear();
callWS.mockClear();
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
unsub = collection.subscribe(() => undefined);
await vi.advanceTimersByTimeAsync(0);
assert.equal(
collection.start.getTime(),
energyPeriodDay(new Date()).start.getTime()
);
// The library's first fetch is not collection.refresh(); this spy only
// sees the extra refresh used during the unsub-grace re-subscribe.
assert.equal(refresh.mock.calls.length, 0);
assert.equal(energyInfoFetches(callWS).length, 1);
unsub();
});
it("keeps a custom setPeriod range overnight", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
const { collection } = createCollection("energy_custom_period");
let unsub = collection.subscribe(() => undefined);
const custom = energyPeriodDay(new Date(), -5);
collection.setPeriod(custom.start, custom.end);
unsub();
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
unsub = collection.subscribe(() => undefined);
assert.equal(collection.start.getTime(), custom.start.getTime());
unsub();
});
it("does not advance the period after the last subscriber leaves", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-20T00:30:00-04:00"));
const { collection } = createCollection("energy_unsub_timer");
const unsub = collection.subscribe(() => undefined);
const start = collection.start.getTime();
unsub();
vi.advanceTimersByTime(48 * 60 * 60 * 1000);
assert.equal(collection.start.getTime(), start);
});
it("does not roll a remembered week preset over to today", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
const { collection } = createCollection("energy_week_stored", "this_week");
const weekStart = collection.start.getTime();
const unsub = collection.subscribe(() => undefined);
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
vi.advanceTimersByTime(48 * 60 * 60 * 1000);
assert.equal(collection.start.getTime(), weekStart);
unsub();
});
it("does not roll a remembered week preset over to today with midnightRollover", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-18T15:00:00-04:00"));
const { collection } = createCollection(
"energy_week_stored_now",
"this_week",
true
);
const weekStart = collection.start.getTime();
const unsub = collection.subscribe(() => undefined);
vi.setSystemTime(new Date("2026-06-20T10:00:00-04:00"));
vi.advanceTimersByTime(48 * 60 * 60 * 1000);
assert.equal(collection.start.getTime(), weekStart);
unsub();
});
});
describe("getEnergyDefaultPeriodStorageKey", () => {
it("uses an explicit collection key", () => {
assert.equal(
getEnergyDefaultPeriodStorageKey(
{ panelUrl: "energy" } as HomeAssistant,
"energy_dashboard"
),
"energy-default-period-_energy_dashboard"
);
});
it("scopes to the panel when no collection key is given", () => {
assert.equal(
getEnergyDefaultPeriodStorageKey({
panelUrl: "my-dashboard",
} as HomeAssistant),
"energy-default-period-_energy_my-dashboard"
);
});
it("falls back to the global key without a panel url", () => {
assert.equal(
getEnergyDefaultPeriodStorageKey({} as HomeAssistant),
"energy-default-period-_energy"
);
});
it("rejects a collection key with the wrong prefix", () => {
assert.throws(() =>
getEnergyDefaultPeriodStorageKey({} as HomeAssistant, "dashboard")
);
});
});
describe("computeEnergyLabel", () => {
const ENTITY_ID = "sensor.washer_energy";
const createEntry = (
entry: Partial<EntityRegistryDisplayEntry>
): EntityRegistryDisplayEntry =>
({
entity_id: ENTITY_ID,
labels: [],
...entry,
}) as EntityRegistryDisplayEntry;
const createDevice = (
device: Partial<DeviceRegistryEntry>
): DeviceRegistryEntry =>
({ id: "device1", name_by_user: null, ...device }) as DeviceRegistryEntry;
const createHass = (
friendlyName: string,
entry?: Partial<EntityRegistryDisplayEntry>,
device?: Partial<DeviceRegistryEntry>
) =>
createMockHass(
{
[ENTITY_ID]: createMockEntityState(ENTITY_ID, "1", {
friendly_name: friendlyName,
}),
},
{
entities: entry ? { [ENTITY_ID]: createEntry(entry) } : {},
devices: device ? { device1: createDevice(device) } : {},
}
);
it("composes the device and entity name", () => {
const hass = createHass(
"Washer Energy",
{ name: "Energy", device_id: "device1" },
{ name: "Washer" }
);
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Washer Energy");
});
it("uses the device name alone when the entity has no name of its own", () => {
const hass = createHass(
"Washer",
{ name: "Washer", device_id: "device1" },
{ name: "Washer" }
);
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Washer");
});
it("distinguishes entities sharing a name by their device", () => {
const hass = createHass(
"Energy",
{ name: "Energy", device_id: "device1" },
{ name: "Dishwasher" }
);
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Dishwasher Energy");
});
it("keeps a name set by the user", () => {
const hass = createHass(
"Washer Energy",
{ name: "Energy", device_id: "device1" },
{ name: "Washer" }
);
assert.equal(
computeEnergyLabel(hass, ENTITY_ID, undefined, "Laundry"),
"Laundry"
);
});
it("ignores an empty name", () => {
const hass = createHass(
"Washer Energy",
{ name: "Energy", device_id: "device1" },
{ name: "Washer" }
);
assert.equal(
computeEnergyLabel(hass, ENTITY_ID, undefined, ""),
"Washer Energy"
);
});
it("falls back to the friendly name for an entity outside the registry", () => {
const hass = createHass("Washer Energy");
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Washer Energy");
});
it("uses the statistic metadata name when there is no entity", () => {
const hass = createMockHass();
assert.equal(
computeEnergyLabel(hass, "external:solar", {
statistic_id: "external:solar",
name: "Solar production",
} as StatisticsMetaData),
"Solar production"
);
});
it("falls back to the statistic id when there is nothing to name it with", () => {
const hass = createMockHass();
assert.equal(computeEnergyLabel(hass, "external:solar"), "external:solar");
});
});
describe("computeEnergyDeviceLabels", () => {
const DEVICES = [
{
stat_consumption: "sensor.washer_energy",
stat_rate: "sensor.washer_power",
},
{ stat_consumption: "sensor.heater_energy", name: "Heater" },
];
const hass = createMockHass({
"sensor.washer_energy": createMockEntityState("sensor.washer_energy", "1", {
friendly_name: "Washer Energy",
}),
"sensor.washer_power": createMockEntityState("sensor.washer_power", "5", {
friendly_name: "Washer Power",
}),
});
it("keys labels by the consumption statistic", () => {
assert.deepEqual(computeEnergyDeviceLabels(hass, DEVICES), {
"sensor.washer_energy": "Washer Energy",
"sensor.heater_energy": "Heater",
});
});
it("keys labels by the rate statistic, skipping devices without one", () => {
assert.deepEqual(
computeEnergyDeviceLabels(hass, DEVICES, undefined, "stat_rate"),
{ "sensor.washer_power": "Washer Power" }
);
});
});