1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-07-06 22:15:19 +01:00
Files
frontend/test/data/history-characterization.test.ts

319 lines
9.2 KiB
TypeScript

/**
* Characterization tests pinning the exact current output of the history
* data-processing pipeline. These exist so performance optimizations can be
* verified to leave behavior bit-identical — see test/benchmarks/README.md.
*
* Do NOT update these snapshots to make an optimization pass; an output
* change is a behavior change and must be escalated instead.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import {
HistoryStream,
computeHistory,
convertStatisticsToHistory,
mergeHistoryResults,
} from "../../src/data/history";
import { digestResult } from "../fixtures/digest";
import {
FIXED_EPOCH_MS,
generateBinarySensorStates,
generateClimateStates,
generateMixedHistory,
generateNumericSensorStates,
} from "../fixtures/history-states";
import {
createMockEntityState,
createMockHass,
mockLocalize,
} from "../fixtures/hass";
import { generateStatistics } from "../fixtures/statistics";
const buildTinyHistory = () => ({
"sensor.power_meter": generateNumericSensorStates(1, { count: 40 }),
"binary_sensor.motion_hall": generateBinarySensorStates(2, { count: 30 }),
"climate.thermostat": generateClimateStates(3, { count: 30 }),
});
describe("computeHistory characterization", () => {
const hass = createMockHass({
"sensor.power_meter": createMockEntityState("sensor.power_meter", "150.0", {
unit_of_measurement: "W",
device_class: "power",
state_class: "measurement",
friendly_name: "Power meter",
}),
"climate.thermostat": createMockEntityState("climate.thermostat", "heat", {
friendly_name: "Thermostat",
current_temperature: 21,
temperature: 21,
}),
});
it("matches snapshot for a mixed payload", () => {
expect(
computeHistory(hass, buildTinyHistory(), [], mockLocalize)
).toMatchSnapshot();
});
it("matches snapshot with splitDeviceClasses enabled", () => {
expect(
computeHistory(hass, buildTinyHistory(), [], mockLocalize, true)
).toMatchSnapshot();
});
it("matches snapshot with forceNumeric enabled", () => {
expect(
computeHistory(hass, buildTinyHistory(), [], mockLocalize, false, true)
).toMatchSnapshot();
});
it("falls back to current state for entities without history", () => {
expect(
computeHistory(
hass,
{},
["sensor.power_meter", "sensor.not_present"],
mockLocalize
)
).toMatchSnapshot();
});
it("large mixed payload digest is stable", () => {
expect(
digestResult(
computeHistory(
hass,
generateMixedHistory(42, "large"),
[],
mockLocalize
)
)
).toMatchSnapshot();
});
it("resolves non-numeric domain units (zone, humidifier, water_heater)", () => {
// Pins the per-domain unit lookup for non-numeric line entities, which the
// mixed/climate fixtures above do not exercise.
const history = {
"zone.home": [
{ s: "2", a: { friendly_name: "Home zone" }, lu: 1_700_000_000 },
{ s: "3", a: {}, lu: 1_700_000_060 },
],
"humidifier.bedroom": [
{
s: "on",
a: {
friendly_name: "Bedroom humidifier",
humidity: 45,
mode: "auto",
},
lu: 1_700_000_000,
},
{ s: "on", a: { humidity: 50, mode: "auto" }, lu: 1_700_000_060 },
],
"water_heater.tank": [
{
s: "eco",
a: {
friendly_name: "Water heater",
temperature: 50,
current_temperature: 48,
},
lu: 1_700_000_000,
},
{
s: "eco",
a: { temperature: 55, current_temperature: 49 },
lu: 1_700_000_060,
},
],
};
expect(computeHistory(hass, history, [], mockLocalize)).toMatchSnapshot();
});
});
describe("HistoryStream characterization", () => {
afterEach(() => {
vi.useRealTimers();
});
it("merges an incremental message and purges expired states", () => {
const nowMs = FIXED_EPOCH_MS + 24 * 60 * 60 * 1000;
vi.useFakeTimers();
vi.setSystemTime(nowMs);
const hoursToShow = 2;
const stream = new HistoryStream(createMockHass(), hoursToShow);
const windowStartMs = nowMs - hoursToShow * 60 * 60 * 1000;
// Initial chunk: 1 hour of data starting just inside the window,
// plus older states that should be purged on the next message.
const initial = stream.processMessage({
states: {
"sensor.power_meter": generateNumericSensorStates(7, {
count: 200,
startMs: windowStartMs - 30 * 60 * 1000,
intervalMs: 60 * 1000,
jitter: 0,
}),
},
});
expect(digestResult(initial)).toMatchSnapshot("initial");
// Incremental update with recent states
const incremental = stream.processMessage({
states: {
"sensor.power_meter": generateNumericSensorStates(8, {
count: 10,
startMs: nowMs - 5 * 60 * 1000,
intervalMs: 30 * 1000,
jitter: 0,
}),
},
});
expect(incremental).toMatchSnapshot("incremental");
});
it("handles multiple entities", () => {
const nowMs = FIXED_EPOCH_MS + 24 * 60 * 60 * 1000;
vi.useFakeTimers();
vi.setSystemTime(nowMs);
const stream = new HistoryStream(createMockHass(), 1);
const result = stream.processMessage({
states: {
"sensor.power_meter": generateNumericSensorStates(9, {
count: 30,
startMs: nowMs - 30 * 60 * 1000,
intervalMs: 60 * 1000,
jitter: 0,
}),
"binary_sensor.motion_hall": generateBinarySensorStates(10, {
count: 20,
startMs: nowMs - 30 * 60 * 1000,
intervalMs: 90 * 1000,
jitter: 0,
}),
},
});
expect(result).toMatchSnapshot();
});
it("adds a new entity, keeps an absent one, and sorts out-of-order states", () => {
const nowMs = FIXED_EPOCH_MS + 24 * 60 * 60 * 1000;
vi.useFakeTimers();
vi.setSystemTime(nowMs);
const hoursToShow = 2;
const stream = new HistoryStream(createMockHass(), hoursToShow);
const windowStartMs = nowMs - hoursToShow * 60 * 60 * 1000;
// Initial chunk with two entities.
const initial = stream.processMessage({
states: {
"sensor.power_a": generateNumericSensorStates(21, {
count: 60,
startMs: windowStartMs + 5 * 60 * 1000,
intervalMs: 60 * 1000,
jitter: 0,
}),
"sensor.power_b": generateNumericSensorStates(22, {
count: 60,
startMs: windowStartMs + 5 * 60 * 1000,
intervalMs: 60 * 1000,
jitter: 0,
}),
},
});
expect(digestResult(initial)).toMatchSnapshot("multi-initial");
// Incremental update that:
// - updates only sensor.power_a (sensor.power_b is absent and must be kept)
// - introduces a brand-new entity sensor.power_c (stream-only branch)
// - delivers sensor.power_a states out of order (older lu than the last
// combined state) to exercise the sort path
const incremental = stream.processMessage({
states: {
"sensor.power_a": generateNumericSensorStates(23, {
count: 10,
startMs: nowMs - 90 * 60 * 1000,
intervalMs: 30 * 1000,
jitter: 0,
}),
"sensor.power_c": generateNumericSensorStates(24, {
count: 15,
startMs: nowMs - 10 * 60 * 1000,
intervalMs: 30 * 1000,
jitter: 0,
}),
},
});
expect(incremental).toMatchSnapshot("multi-incremental");
});
});
describe("convertStatisticsToHistory characterization", () => {
const hass = createMockHass();
it("converts mean-based statistics", () => {
const statistics = generateStatistics(11, {
ids: ["sensor.temperature_indoor"],
period: "hour",
days: 2,
});
expect(
convertStatisticsToHistory(hass, statistics, [
"sensor.temperature_indoor",
])
).toMatchSnapshot();
});
it("converts sum-based statistics and respects id ordering", () => {
const statistics = generateStatistics(12, {
ids: ["sensor.energy_a", "sensor.energy_b"],
period: "hour",
days: 1,
sumStatistics: true,
});
expect(
convertStatisticsToHistory(
hass,
statistics,
["sensor.energy_b", "sensor.energy_a"],
true
)
).toMatchSnapshot();
});
});
describe("mergeHistoryResults characterization", () => {
it("merges recent history with long-term statistics", () => {
const hass = createMockHass();
const dayMs = 24 * 60 * 60 * 1000;
const ltsResult = convertStatisticsToHistory(
hass,
generateStatistics(13, {
ids: ["sensor.power_meter"],
period: "hour",
days: 2,
}),
["sensor.power_meter"]
);
const historyResult = computeHistory(
hass,
{
"sensor.power_meter": generateNumericSensorStates(14, {
count: 50,
startMs: FIXED_EPOCH_MS + 2 * dayMs,
}),
},
[],
mockLocalize
);
expect(mergeHistoryResults(historyResult, ltsResult)).toMatchSnapshot();
expect(mergeHistoryResults(historyResult, undefined)).toBe(historyResult);
});
});