evcc-io/tests/energy-history.spec.ts

254 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { test, expect, type Page, type Locator } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
test.use({ baseURL: baseUrl() });
const from = "2026-03-24T21:00:00+01:00";
const to = "2026-03-25T01:00:00+01:00";
test.beforeAll(async () => {
await start(undefined, "energy-history.sql");
});
test.afterAll(async () => {
await stop();
});
test.describe("api", () => {
test("15-minute resolution", async ({ request }) => {
const res = await request.get(`${baseUrl()}/api/history/energy`, {
params: { from, to },
});
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(data).toHaveLength(2);
const grid = data.find((s: { title: string }) => s.title === "grid");
const home = data.find((s: { title: string }) => s.title === "home");
expect(grid).toBeDefined();
expect(home).toBeDefined();
expect(grid.data).toHaveLength(6);
expect(home.data).toHaveLength(6);
expect(home.data[0].energy).toBeCloseTo(0.1, 4);
expect(home.data[0].returnEnergy).toBeCloseTo(0, 4);
expect(grid.data[0].energy).toBeCloseTo(0.5, 4);
expect(grid.data[0].returnEnergy).toBeCloseTo(0.1, 4);
});
test("day aggregation", async ({ request }) => {
const res = await request.get(`${baseUrl()}/api/history/energy`, {
params: { from, to, aggregate: "day" },
});
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(data).toHaveLength(2);
const grid = data.find((s: { title: string }) => s.title === "grid");
const home = data.find((s: { title: string }) => s.title === "home");
expect(grid).toBeDefined();
expect(home).toBeDefined();
expect(grid.data).toHaveLength(2);
expect(home.data).toHaveLength(2);
const [homeDay1, homeDay2] = home.data;
const [gridDay1, gridDay2] = grid.data;
// day 1 (2026-03-24): 4 slots
expect(homeDay1.energy).toBeCloseTo(0.4, 4);
expect(homeDay1.returnEnergy).toBeCloseTo(0, 4);
expect(gridDay1.energy).toBeCloseTo(2.0, 4);
expect(gridDay1.returnEnergy).toBeCloseTo(0.4, 4);
// day 2 (2026-03-25): 2 slots
expect(homeDay2.energy).toBeCloseTo(0.2, 4);
expect(homeDay2.returnEnergy).toBeCloseTo(0, 4);
expect(gridDay2.energy).toBeCloseTo(1.0, 4);
expect(gridDay2.returnEnergy).toBeCloseTo(0.2, 4);
});
});
async function gotoDay(page: Page, year: number, month: number, day: number): Promise<void> {
await page.goto(`/#/history?period=day&year=${year}&month=${month}&day=${day}`);
await expect(page.getByRole("heading", { name: /history/i }).first()).toBeVisible();
}
async function gotoMonth(page: Page, year: number, month: number): Promise<void> {
await page.goto(`/#/history?period=month&year=${year}&month=${month}`);
await expect(page.getByRole("heading", { name: /history/i }).first()).toBeVisible();
}
function chart(page: Page, group: string): Locator {
return page.getByTestId(`group-chart-${group}`);
}
function section(page: Page, group: string): Locator {
return page.getByTestId(`history-section-${group}`);
}
// Y-axis labels rendered by echarts: position=right uses text-anchor=start.
// DOM order: axis name first, then ticks bottom→top (min, ..., max).
async function yAxis(c: Locator): Promise<string[]> {
const els = c.locator('svg text[text-anchor="start"]');
await els.first().waitFor();
return els.allTextContents();
}
test.describe("axis and units", () => {
test("grid ±2 kW bidirectional, 1 decimal", async ({ page }) => {
await gotoDay(page, 2026, 3, 24);
expect(await yAxis(chart(page, "grid"))).toEqual(["kW", "-2.0", "-1.0", "0.0", "1.0", "2.0"]);
});
test("sub-1 kW switches to W, floored at 1000 W", async ({ page }) => {
await gotoDay(page, 2026, 4, 1);
expect(await yAxis(chart(page, "grid"))).toEqual(["W", "-1,000", "-500", "0", "500", "1,000"]);
});
test("import-only stays symmetric", async ({ page }) => {
await gotoDay(page, 2026, 4, 2);
expect(await yAxis(chart(page, "grid"))).toEqual(["kW", "-2.0", "-1.0", "0.0", "1.0", "2.0"]);
});
test("battery ±3 kW, 1 decimal", async ({ page }) => {
await gotoDay(page, 2026, 4, 3);
expect(await yAxis(chart(page, "battery"))).toEqual([
"kW",
"-3.0",
"-1.5",
"0.0",
"1.5",
"3.0",
]);
});
test("battery ±6 kW, integer labels", async ({ page }) => {
await gotoDay(page, 2026, 4, 4);
expect(await yAxis(chart(page, "battery"))).toEqual(["kW", "-6", "-3", "0", "3", "6"]);
});
test("stacked batteries: axis follows stacked sum, not per-entity max", async ({ page }) => {
await gotoDay(page, 2026, 4, 8);
expect(await yAxis(chart(page, "battery"))).toEqual([
"kW",
"-3.0",
"-1.5",
"0.0",
"1.5",
"3.0",
]);
});
test("unidirectional axis stays positive", async ({ page }) => {
await gotoDay(page, 2026, 4, 5);
// Unidirectional groups use echarts auto-scale (min: 0). Peak 1.6 kW → ticks 0..2.
expect(await yAxis(chart(page, "pv"))).toEqual(["kW", "0.0", "0.5", "1.0", "1.5", "2.0"]);
});
test("all-zero data: W axis at 1000 W", async ({ page }) => {
await gotoDay(page, 2026, 4, 6);
await expect(section(page, "pv")).toBeVisible();
expect(await yAxis(chart(page, "pv"))).toEqual(["W", "0", "250", "500", "750", "1,000"]);
});
test("stacked entities + overlay not clipped", async ({ page }) => {
await gotoDay(page, 2026, 5, 2);
// Stacked east+west peak = 1.8 kWh × 4 = 7.2 kW → niceCeil = 8.
expect(await yAxis(chart(page, "pv"))).toEqual(["kW", "0", "2", "4", "6", "8"]);
});
test("month view uses kWh unit", async ({ page }) => {
await gotoMonth(page, 2026, 6);
expect(await yAxis(chart(page, "battery"))).toEqual([
"kWh",
"-2.0",
"-1.0",
"0.0",
"1.0",
"2.0",
]);
});
});
test.describe("consumption breakdown", () => {
// 2026-04-07: home = 1.0 kWh, Kitchen = 0.4 kWh, Office = 0.3 kWh,
// virtual Others = home − meters = 0.3 kWh.
test("home total, meter legend, virtual Others", async ({ page }) => {
await gotoDay(page, 2026, 4, 7);
const consumption = section(page, "consumer");
await expect(consumption).toBeVisible();
// Section total = home, not sum of meters.
await expect(consumption.getByRole("heading")).toContainText("1.0 kWh");
// Others (virtual) + explicit meters.
await expect(consumption.getByRole("button", { name: "Others 300 Wh" })).toBeVisible();
await expect(consumption.getByRole("button", { name: "Kitchen 400 Wh" })).toBeVisible();
await expect(consumption.getByRole("button", { name: "Office 300 Wh" })).toBeVisible();
});
// 2026-03-24: home = 0.4 kWh, no meter entities with data.
test("home without meters shows chart without legend", async ({ page }) => {
await gotoDay(page, 2026, 3, 24);
const consumption = section(page, "consumer");
await expect(consumption).toBeVisible();
await expect(consumption.getByRole("heading")).toContainText("0.4 kWh");
// No explicit consumers, no entity legend.
await expect(consumption.getByRole("button")).toHaveCount(0);
});
test("entity focus rescales axis and resets on unfocus", async ({ page }) => {
await gotoDay(page, 2026, 4, 7);
const consumption = section(page, "consumer");
const meterChart = chart(page, "consumer");
// Stacked total per slot 1.0 kW → echarts auto-axis 0..1.2 in 1-decimal.
expect(await yAxis(meterChart)).toEqual(["kW", "0.0", "0.3", "0.6", "0.9", "1.2"]);
// Focus Kitchen → peak 400 W → unit switches to W, floored at 1000 W.
const kitchen = consumption.getByRole("button", { name: "Kitchen 400 Wh" });
await kitchen.click();
await expect.poll(() => yAxis(meterChart)).toEqual(["W", "0", "250", "500", "750", "1,000"]);
// Unfocus → axis returns to kW range, not stuck on 1000 W cap.
await kitchen.click();
await expect.poll(() => yAxis(meterChart)).toEqual(["kW", "0.0", "0.3", "0.6", "0.9", "1.2"]);
});
});
test.describe("additional meters", () => {
// 2026-04-09: single ext meter "Submeter" = 1.2 kWh, no home data.
test("standalone section without virtual Others", async ({ page }) => {
await gotoDay(page, 2026, 4, 9);
const additional = section(page, "meter");
await expect(additional).toBeVisible();
// No section total: additional meters can be import, export, or consumption.
await expect(additional.getByRole("heading")).not.toContainText("kWh");
// Explicit entity legend, no virtual "Others" (unlike the consumer group).
await expect(additional.getByRole("button", { name: "Submeter 1.2 kWh" })).toBeVisible();
await expect(additional.getByText("Others", { exact: true })).toBeHidden();
});
// 2026-04-10: same meter with export. Negatives flip the axis to symmetric.
test("bidirectional axis when data contains exports", async ({ page }) => {
await gotoDay(page, 2026, 4, 10);
expect(await yAxis(chart(page, "meter"))).toEqual(["kW", "-2.0", "-1.0", "0.0", "1.0", "2.0"]);
});
});
test.describe("reconnect", () => {
test("history still shows content after backend restart", async ({ page }) => {
await gotoDay(page, 2026, 3, 24);
await expect(chart(page, "grid")).toBeVisible();
await restart();
await expect(chart(page, "grid")).toBeVisible();
});
});