From d43ab9fa6abb12b1fc0f5a8c898780b24b97016a Mon Sep 17 00:00:00 2001 From: Maschga <88616799+Maschga@users.noreply.github.com> Date: Sun, 15 Jun 2025 23:23:22 +0200 Subject: [PATCH] chore: Typescript: Rewrite tests (#21723) Co-authored-by: Michael Geers --- assets/js/store.ts | 4 +- assets/js/types/evcc.ts | 1 + package-lock.json | 33 + package.json | 2 + tests/{auth.spec.js => auth.spec.ts} | 0 tests/{basics.spec.js => basics.spec.ts} | 2 +- ...tings.spec.js => battery-settings.spec.ts} | 0 tests/{boot.spec.js => boot.spec.ts} | 0 ...{config-aux.spec.js => config-aux.spec.ts} | 4 +- tests/config-battery.spec.js | 87 --- tests/config-battery.spec.ts | 89 +++ ...circuit.spec.js => config-circuit.spec.ts} | 0 ...onfig-grid.spec.js => config-grid.spec.ts} | 0 tests/config-loadpoint.spec.js | 500 -------------- tests/config-loadpoint.spec.ts | 512 ++++++++++++++ tests/config-messaging.spec.js | 44 -- tests/config-messaging.spec.ts | 44 ++ ...onfig-mqtt.spec.js => config-mqtt.spec.ts} | 6 +- ...ding.spec.js => config-onboarding.spec.ts} | 0 tests/config-pv.spec.js | 114 ---- tests/config-pv.spec.ts | 116 ++++ tests/config-tariffs.spec.js | 106 --- tests/config-tariffs.spec.ts | 106 +++ ...hicles.spec.js => config-vehicles.spec.ts} | 0 tests/{config.spec.js => config.spec.ts} | 0 ...{custom-css.spec.js => custom-css.spec.ts} | 0 tests/evcc.js | 136 ---- tests/evcc.ts | 140 ++++ tests/{fatal.spec.js => fatal.spec.ts} | 0 tests/{heating.spec.js => heating.spec.ts} | 0 tests/{limits.spec.js => limits.spec.ts} | 0 tests/{logs.spec.js => logs.spec.ts} | 0 tests/{modals.spec.js => modals.spec.ts} | 0 tests/plan.spec.js | 630 ----------------- tests/plan.spec.ts | 646 ++++++++++++++++++ tests/sessions.spec.js | 332 --------- tests/sessions.spec.ts | 343 ++++++++++ tests/simulator.js | 77 --- tests/simulator.ts | 77 +++ tests/simulator/api.js | 103 --- tests/simulator/api.ts | 129 ++++ tests/simulator/src/Simulator.vue | 34 +- tests/simulator/src/{main.js => main.ts} | 0 tests/simulator/vite.config.js | 8 - tests/simulator/vite.config.ts | 12 + ...t-only.spec.js => smart-cost-only.spec.ts} | 0 ...{smart-cost.spec.js => smart-cost.spec.ts} | 0 ...{statistics.spec.js => statistics.spec.ts} | 0 tests/utils.ts | 2 +- ...le-error.spec.js => vehicle-error.spec.ts} | 0 ...tings.spec.js => vehicle-settings.spec.ts} | 0 tests/{ws.spec.js => ws.spec.ts} | 0 tsconfig.json | 2 +- 53 files changed, 2287 insertions(+), 2154 deletions(-) rename tests/{auth.spec.js => auth.spec.ts} (100%) rename tests/{basics.spec.js => basics.spec.ts} (98%) rename tests/{battery-settings.spec.js => battery-settings.spec.ts} (100%) rename tests/{boot.spec.js => boot.spec.ts} (100%) rename tests/{config-aux.spec.js => config-aux.spec.ts} (98%) delete mode 100644 tests/config-battery.spec.js create mode 100644 tests/config-battery.spec.ts rename tests/{config-circuit.spec.js => config-circuit.spec.ts} (100%) rename tests/{config-grid.spec.js => config-grid.spec.ts} (100%) delete mode 100644 tests/config-loadpoint.spec.js create mode 100644 tests/config-loadpoint.spec.ts delete mode 100644 tests/config-messaging.spec.js create mode 100644 tests/config-messaging.spec.ts rename tests/{config-mqtt.spec.js => config-mqtt.spec.ts} (95%) rename tests/{config-onboarding.spec.js => config-onboarding.spec.ts} (100%) delete mode 100644 tests/config-pv.spec.js create mode 100644 tests/config-pv.spec.ts delete mode 100644 tests/config-tariffs.spec.js create mode 100644 tests/config-tariffs.spec.ts rename tests/{config-vehicles.spec.js => config-vehicles.spec.ts} (100%) rename tests/{config.spec.js => config.spec.ts} (100%) rename tests/{custom-css.spec.js => custom-css.spec.ts} (100%) delete mode 100644 tests/evcc.js create mode 100644 tests/evcc.ts rename tests/{fatal.spec.js => fatal.spec.ts} (100%) rename tests/{heating.spec.js => heating.spec.ts} (100%) rename tests/{limits.spec.js => limits.spec.ts} (100%) rename tests/{logs.spec.js => logs.spec.ts} (100%) rename tests/{modals.spec.js => modals.spec.ts} (100%) delete mode 100644 tests/plan.spec.js create mode 100644 tests/plan.spec.ts delete mode 100644 tests/sessions.spec.js create mode 100644 tests/sessions.spec.ts delete mode 100644 tests/simulator.js create mode 100644 tests/simulator.ts delete mode 100644 tests/simulator/api.js create mode 100644 tests/simulator/api.ts rename tests/simulator/src/{main.js => main.ts} (100%) delete mode 100644 tests/simulator/vite.config.js create mode 100644 tests/simulator/vite.config.ts rename tests/{smart-cost-only.spec.js => smart-cost-only.spec.ts} (100%) rename tests/{smart-cost.spec.js => smart-cost.spec.ts} (100%) rename tests/{statistics.spec.js => statistics.spec.ts} (100%) rename tests/{vehicle-error.spec.js => vehicle-error.spec.ts} (100%) rename tests/{vehicle-settings.spec.js => vehicle-settings.spec.ts} (100%) rename tests/{ws.spec.js => ws.spec.ts} (100%) diff --git a/assets/js/store.ts b/assets/js/store.ts index d659080df..d112ce02a 100644 --- a/assets/js/store.ts +++ b/assets/js/store.ts @@ -26,7 +26,9 @@ function setProperty(obj: object, props: string[], value: any) { const initialState: State = { offline: false, - loadpoints: [], // ensure array type + // ensure array type + loadpoints: [], + vehicles: [], }; const state = reactive(initialState); diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 000873e0c..203af9cec 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -31,6 +31,7 @@ export interface State { currency?: CURRENCY; fatal?: FatalError; auth?: Auth; + vehicles: Vehicle[]; } export interface LoadpointCompact { diff --git a/package-lock.json b/package-lock.json index f642759e4..4538986ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,9 @@ "@storybook/test": "^8.6.4", "@storybook/vue3": "^8.6.4", "@storybook/vue3-vite": "^8.6.4", + "@types/body-parser": "^1.19.6", "@types/smoothscroll-polyfill": "^0.3.4", + "@types/wait-on": "^5.3.4", "cross-env": "^7.0.3", "eslint-plugin-storybook": "^0.12.0", "prettier-plugin-sort-json": "^4.1.1", @@ -3391,6 +3393,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/bootstrap": { "version": "5.2.10", "resolved": "https://registry.npmjs.org/@types/bootstrap/-/bootstrap-5.2.10.tgz", @@ -3406,6 +3419,16 @@ "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", "license": "MIT" }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -3474,6 +3497,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/wait-on": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/@types/wait-on/-/wait-on-5.3.4.tgz", + "integrity": "sha512-EBsPjFMrFlMbbUFf9D1Fp+PAB2TwmUn7a3YtHyD9RLuTIk1jDd8SxXVAoez2Ciy+8Jsceo2MYEYZzJ/DvorOKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.33.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.0.tgz", diff --git a/package.json b/package.json index aeca7a9ce..5358f6d5e 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,8 @@ "@storybook/test": "^8.6.4", "@storybook/vue3": "^8.6.4", "@storybook/vue3-vite": "^8.6.4", + "@types/body-parser": "^1.19.6", + "@types/wait-on": "^5.3.4", "@types/smoothscroll-polyfill": "^0.3.4", "cross-env": "^7.0.3", "eslint-plugin-storybook": "^0.12.0", diff --git a/tests/auth.spec.js b/tests/auth.spec.ts similarity index 100% rename from tests/auth.spec.js rename to tests/auth.spec.ts diff --git a/tests/basics.spec.js b/tests/basics.spec.ts similarity index 98% rename from tests/basics.spec.js rename to tests/basics.spec.ts index e77fc315b..98a8fc152 100644 --- a/tests/basics.spec.js +++ b/tests/basics.spec.ts @@ -86,7 +86,7 @@ test.describe("network requests", async () => { test("no failed requests", async ({ page }) => { await page.waitForLoadState("networkidle"); - const failedRequests = []; + const failedRequests: string[] = []; page.on("requestfailed", (request) => failedRequests.push(request.url())); await page.reload(); diff --git a/tests/battery-settings.spec.js b/tests/battery-settings.spec.ts similarity index 100% rename from tests/battery-settings.spec.js rename to tests/battery-settings.spec.ts diff --git a/tests/boot.spec.js b/tests/boot.spec.ts similarity index 100% rename from tests/boot.spec.js rename to tests/boot.spec.ts diff --git a/tests/config-aux.spec.js b/tests/config-aux.spec.ts similarity index 98% rename from tests/config-aux.spec.js rename to tests/config-aux.spec.ts index d8cf0b88e..edf6c26c2 100644 --- a/tests/config-aux.spec.js +++ b/tests/config-aux.spec.ts @@ -38,7 +38,7 @@ test.describe("aux meter", async () => { await expectModalHidden(meterModal); // check - await expect(page.getByTestId("aux")).toBeVisible(1); + await expect(page.getByTestId("aux")).toBeVisible(); await expect(page.getByTestId("aux")).toContainText("Water heater"); await expect(page.getByTestId("aux")).toContainText("1.2 kW"); @@ -47,7 +47,7 @@ test.describe("aux meter", async () => { await page.reload(); // recheck - await expect(page.getByTestId("aux")).toBeVisible(1); + await expect(page.getByTestId("aux")).toBeVisible(); await expect(page.getByTestId("aux")).toContainText("Water heater"); await expect(page.getByTestId("aux")).toContainText("1.2 kW"); diff --git a/tests/config-battery.spec.js b/tests/config-battery.spec.js deleted file mode 100644 index 9dfe0bf38..000000000 --- a/tests/config-battery.spec.js +++ /dev/null @@ -1,87 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { start, stop, restart, baseUrl } from "./evcc"; -import { enableExperimental, expectModalHidden, expectModalVisible } from "./utils"; - -const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml"; - -test.use({ baseURL: baseUrl() }); - -test.beforeAll(async () => { - await start(CONFIG_GRID_ONLY); -}); -test.afterAll(async () => { - await stop(); -}); - -test.describe("battery meter", async () => { - test("create, edit and remove battery meter", async ({ page }) => { - await page.goto("/#/config"); - await enableExperimental(page); - - await expect(page.getByTestId("battery")).toHaveCount(0); - - // create #1 - await page.getByRole("button", { name: "Add solar or battery" }).click(); - - const meterModal = page.getByTestId("meter-modal"); - await meterModal.getByRole("button", { name: "Add battery meter" }).click(); - await meterModal.getByLabel("Title").fill("Demo Battery"); - await meterModal.getByLabel("Manufacturer").selectOption("Demo battery"); - await meterModal.getByLabel("Power").fill("4000"); - await meterModal.getByLabel("Charge").fill("80"); - await expect(meterModal.getByLabel("Maximum AC power of the hybrid inverter")).toHaveCount(0); - await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible(); - await meterModal.getByRole("link", { name: "validate" }).click(); - await expect(meterModal.getByTestId("device-tag-power")).toContainText("4.0 kW"); - await expect(meterModal.getByTestId("device-tag-soc")).toContainText("80.0%"); - await meterModal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(meterModal); - await expect(page.getByTestId("battery")).toBeVisible(1); - await expect(page.getByTestId("battery")).toContainText("Demo Battery"); - - // edit #1 - await page.getByTestId("battery").getByRole("button", { name: "edit" }).click(); - await expectModalVisible(meterModal); - await meterModal.getByLabel("Power").fill("5000"); - await meterModal.getByRole("button", { name: "Validate & save" }).click(); - await expectModalHidden(meterModal); - - const battery = page.getByTestId("battery"); - await expect(battery).toBeVisible(1); - await expect(battery).toContainText("Demo Battery"); - await expect(battery.getByTestId("device-tag-power")).toContainText("5.0 kW"); - await expect(battery.getByTestId("device-tag-soc")).toContainText("80.0%"); - - // restart and check in main ui - await restart(CONFIG_GRID_ONLY); - await page.goto("/"); - await page.getByTestId("visualization").click(); - await expect(page.getByTestId("energyflow")).toContainText("Battery discharging80%5.0 kW"); - - // delete #1 - await page.goto("/#/config"); - await page.getByTestId("battery").getByRole("button", { name: "edit" }).click(); - await expectModalVisible(meterModal); - await meterModal.getByRole("button", { name: "Delete" }).click(); - await expectModalHidden(meterModal); - await expect(page.getByTestId("battery")).toHaveCount(0); - }); - - test("advanced fields", async ({ page }) => { - await page.goto("/#/config"); - await enableExperimental(page); - - await page.getByRole("button", { name: "Add solar or battery" }).click(); - - const meterModal = page.getByTestId("meter-modal"); - await meterModal.getByRole("button", { name: "Add battery meter" }).click(); - await meterModal.getByLabel("Title").fill("Demo Battery"); - await meterModal.getByLabel("Manufacturer").selectOption("Demo battery"); - - await expect(meterModal.getByLabel("Meter reading")).not.toBeVisible(); - await page.getByRole("button", { name: "Show advanced settings" }).click(); - await expect(meterModal.getByLabel("Meter reading")).toBeVisible(); - await page.getByRole("button", { name: "Hide advanced settings" }).click(); - await expect(meterModal.getByLabel("Meter reading")).not.toBeVisible(); - }); -}); diff --git a/tests/config-battery.spec.ts b/tests/config-battery.spec.ts new file mode 100644 index 000000000..04fdf4a63 --- /dev/null +++ b/tests/config-battery.spec.ts @@ -0,0 +1,89 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, restart, baseUrl } from "./evcc"; +import { enableExperimental, expectModalHidden, expectModalVisible } from "./utils"; + +const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml"; + +test.use({ baseURL: baseUrl() }); + +test.beforeAll(async () => { + await start(CONFIG_GRID_ONLY); +}); +test.afterAll(async () => { + await stop(); +}); + +test.describe("battery meter", async () => { + test("create, edit and remove battery meter", async ({ page }) => { + await page.goto("/#/config"); + await enableExperimental(page); + + await expect(page.getByTestId("battery")).toHaveCount(0); + + // create #1 + await page.getByRole("button", { name: "Add solar or battery" }).click(); + + const meterModal = page.getByTestId("meter-modal"); + await meterModal.getByRole("button", { name: "Add battery meter" }).click(); + await meterModal.getByLabel("Title").fill("Demo Battery"); + await meterModal.getByLabel("Manufacturer").selectOption("Demo battery"); + await meterModal.getByLabel("Power").fill("4000"); + await meterModal.getByLabel("Charge").fill("80"); + await expect(meterModal.getByLabel("Maximum AC power of the hybrid inverter")).toHaveCount( + 0 + ); + await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible(); + await meterModal.getByRole("link", { name: "validate" }).click(); + await expect(meterModal.getByTestId("device-tag-power")).toContainText("4.0 kW"); + await expect(meterModal.getByTestId("device-tag-soc")).toContainText("80.0%"); + await meterModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(meterModal); + await expect(page.getByTestId("battery")).toBeVisible(); + await expect(page.getByTestId("battery")).toContainText("Demo Battery"); + + // edit #1 + await page.getByTestId("battery").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(meterModal); + await meterModal.getByLabel("Power").fill("5000"); + await meterModal.getByRole("button", { name: "Validate & save" }).click(); + await expectModalHidden(meterModal); + + const battery = page.getByTestId("battery"); + await expect(battery).toBeVisible(); + await expect(battery).toContainText("Demo Battery"); + await expect(battery.getByTestId("device-tag-power")).toContainText("5.0 kW"); + await expect(battery.getByTestId("device-tag-soc")).toContainText("80.0%"); + + // restart and check in main ui + await restart(CONFIG_GRID_ONLY); + await page.goto("/"); + await page.getByTestId("visualization").click(); + await expect(page.getByTestId("energyflow")).toContainText("Battery discharging80%5.0 kW"); + + // delete #1 + await page.goto("/#/config"); + await page.getByTestId("battery").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(meterModal); + await meterModal.getByRole("button", { name: "Delete" }).click(); + await expectModalHidden(meterModal); + await expect(page.getByTestId("battery")).toHaveCount(0); + }); + + test("advanced fields", async ({ page }) => { + await page.goto("/#/config"); + await enableExperimental(page); + + await page.getByRole("button", { name: "Add solar or battery" }).click(); + + const meterModal = page.getByTestId("meter-modal"); + await meterModal.getByRole("button", { name: "Add battery meter" }).click(); + await meterModal.getByLabel("Title").fill("Demo Battery"); + await meterModal.getByLabel("Manufacturer").selectOption("Demo battery"); + + await expect(meterModal.getByLabel("Meter reading")).not.toBeVisible(); + await page.getByRole("button", { name: "Show advanced settings" }).click(); + await expect(meterModal.getByLabel("Meter reading")).toBeVisible(); + await page.getByRole("button", { name: "Hide advanced settings" }).click(); + await expect(meterModal.getByLabel("Meter reading")).not.toBeVisible(); + }); +}); diff --git a/tests/config-circuit.spec.js b/tests/config-circuit.spec.ts similarity index 100% rename from tests/config-circuit.spec.js rename to tests/config-circuit.spec.ts diff --git a/tests/config-grid.spec.js b/tests/config-grid.spec.ts similarity index 100% rename from tests/config-grid.spec.js rename to tests/config-grid.spec.ts diff --git a/tests/config-loadpoint.spec.js b/tests/config-loadpoint.spec.js deleted file mode 100644 index 5357643b3..000000000 --- a/tests/config-loadpoint.spec.js +++ /dev/null @@ -1,500 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { start, stop, restart, baseUrl } from "./evcc"; -import { expectModalVisible, expectModalHidden, editorClear, editorPaste } from "./utils"; - -const CONFIG_EMPTY = "config-empty.evcc.yaml"; -const CONFIG_ONE_LP = "config-one-lp.evcc.yaml"; - -test.use({ baseURL: baseUrl() }); - -test.afterEach(async () => { - await stop(); -}); - -async function enableExperimental(page) { - await page - .getByTestId("generalconfig-experimental") - .getByRole("button", { name: "edit" }) - .click(); - await page.getByLabel("Experimental 🧪").click(); - await page.getByRole("button", { name: "Close" }).click(); -} - -async function addDemoCharger(page) { - const lpModal = page.getByTestId("loadpoint-modal"); - await lpModal.getByRole("button", { name: "Add charger" }).click(); - - const modal = page.getByTestId("charger-modal"); - await expectModalVisible(modal); - await modal.getByLabel("Manufacturer").selectOption("Demo charger"); - await modal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(modal); - await expectModalVisible(lpModal); -} - -async function addDemoMeter(page, power = "0") { - const lpModal = page.getByTestId("loadpoint-modal"); - await lpModal.getByRole("button", { name: "Add dedicated charger meter" }).click(); - - const modal = page.getByTestId("meter-modal"); - await expectModalVisible(modal); - await modal.getByLabel("Manufacturer").selectOption("Demo meter"); - await modal.getByLabel("Power").fill(power); - await modal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(modal); - await expectModalVisible(lpModal); -} - -async function addVehicle(page, title) { - await page.getByRole("button", { name: "Add vehicle" }).click(); - const modal = page.getByTestId("vehicle-modal"); - await expectModalVisible(modal); - await modal.getByLabel("Manufacturer").selectOption("Generic vehicle (without API)"); - await modal.getByLabel("Title").fill(title); - await modal.getByRole("button", { name: "Validate & save" }).click(); - await expectModalHidden(modal); -} - -async function newLoadpoint(page, title) { - const lpModal = page.getByTestId("loadpoint-modal"); - await page.getByRole("button", { name: "Add charge point" }).click(); - await expectModalVisible(lpModal); - await lpModal.getByLabel("Title").fill(title); -} - -test.describe("loadpoint", async () => { - test("create, update and delete", async ({ page }) => { - await start(CONFIG_EMPTY); - - await page.goto("/#/config"); - await enableExperimental(page); - - const lpModal = page.getByTestId("loadpoint-modal"); - const chargerModal = page.getByTestId("charger-modal"); - - await expect(page.getByTestId("loadpoint")).toHaveCount(0); - - // new loadpoint - await newLoadpoint(page, "Solar Carport"); - await lpModal.getByRole("button", { name: "Add charger" }).click(); - - // add charger - await chargerModal.getByLabel("Manufacturer").selectOption("Demo charger"); - await chargerModal.getByLabel("Charge status").selectOption("C"); - await chargerModal.getByLabel("Power").fill("11000"); - await chargerModal.getByRole("button", { name: "Save" }).click(); - await expect(lpModal.getByLabel("Title")).toHaveValue("Solar Carport"); - - // create loadpoint - await lpModal.getByRole("button", { name: "Save" }).click(); - await expect(page.getByTestId("loadpoint")).toHaveCount(1); - await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport"); - await expect(page.getByTestId("loadpoint")).toContainText("charging"); - await expect(page.getByTestId("loadpoint")).toContainText("11.0 kW"); - - // restart button appears - const restartButton = await page - .getByTestId("bottom-banner") - .getByRole("button", { name: "Restart" }); - await expect(restartButton).toBeVisible(); - - // restart - await restart(CONFIG_EMPTY); - await page.reload(); - await expect(page.getByTestId("loadpoint")).toHaveCount(1); - await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport"); - - // update loadpoint title - await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); - await expectModalVisible(lpModal); - await lpModal.getByLabel("Title").fill("Solar Carport 2"); - await lpModal.getByRole("button", { name: "Save" }).click(); - await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2"); - - // restart - await restart(CONFIG_EMPTY); - await page.reload(); - await expect(page.getByTestId("loadpoint")).toHaveCount(1); - await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2"); - - // update loadpoint power - await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); - await expectModalVisible(lpModal); - await lpModal.getByTestId("chargerPower-22kw").click(); - - // update charger mode - await expect(lpModal.getByRole("textbox", { name: "Charger" })).toHaveValue( - "Demo charger [db:1]" - ); - await lpModal.getByRole("textbox", { name: "Charger" }).click(); - await chargerModal.getByLabel("Charge status").selectOption("A"); - await chargerModal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(chargerModal); - - await lpModal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(lpModal); - - // restart - await restart(CONFIG_EMPTY); - await page.reload(); - await expect(page.getByTestId("loadpoint")).toHaveCount(1); - await expect(page.getByTestId("loadpoint")).toContainText("not connected"); - - await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); - await expectModalVisible(lpModal); - await expect(lpModal.getByTestId("chargerPower-22kw")).toHaveClass(/active/); - await expect(lpModal.getByLabel("Title")).toHaveValue("Solar Carport 2"); - await lpModal.getByRole("button", { name: "Close" }).click(); - - // delete loadpoint - await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); - await expectModalVisible(lpModal); - await lpModal.getByRole("button", { name: "Delete" }).click(); - await expect(page.getByTestId("loadpoint")).toHaveCount(0); - - // restart - await restart(CONFIG_EMPTY); - await page.reload(); - await expect(page.getByTestId("loadpoint")).toHaveCount(0); - }); - - test("priority", async ({ page }) => { - await start(CONFIG_ONE_LP); - await page.goto("/#/config"); - await enableExperimental(page); - - const lpModal = page.getByTestId("loadpoint-modal"); - - // loadpoint from yaml - await expect(page.getByTestId("loadpoint")).toHaveCount(1); - await expect(page.getByTestId("loadpoint")).toContainText("Carport"); - - // add loadpoint via UI - await newLoadpoint(page, "Garage"); - await addDemoCharger(page); - await lpModal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(lpModal); - - // two loadpoints - await expect(page.getByTestId("loadpoint")).toHaveCount(2); - await expect(page.getByTestId("loadpoint").nth(0)).toContainText("Carport"); - await expect(page.getByTestId("loadpoint").nth(1)).toContainText("Garage"); - - // second loadpoint: increase priority - await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click(); - await expectModalVisible(lpModal); - await expect(lpModal.getByLabel("Priority")).toHaveValue("0 (default)"); - await lpModal.getByLabel("Priority").selectOption("1"); - await lpModal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(lpModal); - - // restart - await restart(CONFIG_ONE_LP); - await page.reload(); - - // check priorities - await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click(); - await expectModalVisible(lpModal); - await expect(lpModal.getByLabel("Priority")).toHaveValue("1"); - - // change back to 0 - await lpModal.getByLabel("Priority").selectOption("0 (default)"); - await lpModal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(lpModal); - - // restart - await restart(CONFIG_ONE_LP); - await page.reload(); - - // check priorities - await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click(); - await expectModalVisible(lpModal); - await expect(lpModal.getByLabel("Priority")).toHaveValue("0 (default)"); - }); - - test("vehicle", async ({ page }) => { - await start(CONFIG_EMPTY); - await page.goto("/#/config"); - await enableExperimental(page); - - const LP_1 = "Carport"; - const LP_2 = "Garage"; - const VEHICLE_1 = "Green Car"; - const VEHICLE_2 = "Yellow Van"; - - const lpModal = page.getByTestId("loadpoint-modal"); - - // add loadpoint > no vehicle option - await newLoadpoint(page, LP_1); - await addDemoCharger(page); - await expect(lpModal.getByLabel("Default vehicle")).not.toBeVisible(); - await lpModal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(lpModal); - - // edit loadpoint - await page.getByTestId("loadpoint").nth(0).getByRole("button", { name: "edit" }).click(); - await expectModalVisible(lpModal); - await expect(lpModal).toContainText("No vehicles are configured."); - await lpModal.getByRole("button", { name: "Close" }).click(); - - // add vehicles - for (const title of [VEHICLE_1, VEHICLE_2]) { - await addVehicle(page, title); - } - - // set vehicle as default for loadpoint 1 - await page.getByTestId("loadpoint").nth(0).getByRole("button", { name: "edit" }).click(); - await expectModalVisible(lpModal); - await expect(lpModal.getByTestId("loadpointPollMode-charging")).toHaveClass(/active/); - await expect(lpModal.getByRole("checkbox", { name: "Interpolate charge level" })).toBeChecked(); - await lpModal.getByLabel("Default vehicle").selectOption(VEHICLE_1); - await lpModal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(lpModal); - - // add second loadpoint - await newLoadpoint(page, LP_2); - await addDemoCharger(page); - await lpModal.getByLabel("Default vehicle").selectOption(VEHICLE_2); - await lpModal.getByRole("button", { name: "Save" }).click(); - - // restart - await restart(CONFIG_EMPTY); - await page.reload(); - - // check loadpoint default vehicles - for (const [index, vehicle] of [VEHICLE_1, VEHICLE_2].entries()) { - await page.getByTestId("loadpoint").nth(index).getByRole("button", { name: "edit" }).click(); - await expectModalVisible(lpModal); - await expect(lpModal.locator("#loadpointParamVehicle option:checked")).toHaveText(vehicle); - await lpModal.getByRole("button", { name: "Close" }).click(); - await expectModalHidden(lpModal); - } - }); - - test("keep mode", async ({ page }) => { - await start(CONFIG_EMPTY); - await page.goto("/#/config"); - await enableExperimental(page); - - // add grid meter - await page.getByRole("button", { name: "Add grid meter" }).click(); - const meterModal = page.getByTestId("meter-modal"); - await meterModal.getByLabel("Manufacturer").selectOption("Demo meter"); - await meterModal.getByLabel("Power").fill("-1000"); - await meterModal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(meterModal); - - // add a loadpoint with dummy charger, - const lpModal = page.getByTestId("loadpoint-modal"); - await newLoadpoint(page, "Carport"); - await addDemoCharger(page); - await lpModal.getByLabel("Default mode").selectOption("---"); - await lpModal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(lpModal); - await restart(CONFIG_EMPTY); - - // change on main ui - await page.goto("/"); - await expect(page.getByRole("button", { name: "Off" })).toHaveClass(/active/); - await page.getByRole("button", { name: "Solar", exact: true }).click(); - await restart(CONFIG_EMPTY); - await page.reload(); - await expect(page.getByRole("button", { name: "Solar", exact: true })).toHaveClass(/active/); - - // change default mode in config to fast - await page.goto("/#/config"); - // open first loadpoint - await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); - await expectModalVisible(lpModal); - await lpModal.getByLabel("Default mode").selectOption("Fast"); - await lpModal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(lpModal); - await restart(CONFIG_EMPTY); - - // check loadpoint mode - await page.goto("/"); - await expect(page.getByRole("button", { name: "Fast" })).toHaveClass(/active/); - }); - - test("delete vehicle references", async ({ page }) => { - await start(CONFIG_EMPTY); - await page.goto("/#/config"); - await enableExperimental(page); - - // add vehicle, add loadpoint, add charger - await addVehicle(page, "Porsche"); - await addVehicle(page, "Tesla"); - await newLoadpoint(page, "Garage"); - await addDemoCharger(page); - const lpModal = page.getByTestId("loadpoint-modal"); - await expectModalVisible(lpModal); - await lpModal.getByLabel("Default vehicle").selectOption("Porsche"); - await lpModal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(lpModal); - - // delete vehicle - await page.getByTestId("vehicle").nth(0).getByRole("button", { name: "edit" }).click(); - const vehicleModal = page.getByTestId("vehicle-modal"); - await expectModalVisible(vehicleModal); - await vehicleModal.getByRole("button", { name: "Delete" }).click(); - await expectModalHidden(vehicleModal); - - // restart - await restart(CONFIG_EMPTY); - await page.reload(); - - // check loadpoint default vehicle - await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); - await expectModalVisible(lpModal); - await expect(lpModal.getByLabel("Default vehicle")).toHaveValue(""); - }); - - test("delete charger references", async ({ page }) => { - await start(CONFIG_EMPTY); - await page.goto("/#/config"); - await enableExperimental(page); - - // add loadpoint, add charger - await newLoadpoint(page, "Garage"); - await addDemoCharger(page); - const lpModal = page.getByTestId("loadpoint-modal"); - await lpModal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(lpModal); - - // delete charger - await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); - await expectModalVisible(lpModal); - await lpModal.getByRole("textbox", { name: "Charger" }).click(); - const chargerModal = page.getByTestId("charger-modal"); - await expectModalVisible(chargerModal); - await chargerModal.getByRole("button", { name: "Delete" }).click(); - await expectModalHidden(chargerModal); - - // restart without saving loadpoint - await restart(CONFIG_EMPTY); - await page.reload(); - - // check loadpoint default vehicle - await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); - await expectModalVisible(lpModal); - await expect(lpModal.getByRole("textbox", { name: "Title" })).toHaveValue("Garage"); - await expect(lpModal).toContainText("Configuring a charger is required."); - }); - - test("delete meter references", async ({ page }) => { - await start(CONFIG_EMPTY); - await page.goto("/#/config"); - await enableExperimental(page); - - // add loadpoint, add charger - await newLoadpoint(page, "Garage"); - await addDemoCharger(page); - await addDemoMeter(page, "11000"); - const lpModal = page.getByTestId("loadpoint-modal"); - await lpModal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(lpModal); - await expect(page.getByTestId("loadpoint")).toContainText("11.0 kW"); - - // delete charger - await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); - await expectModalVisible(lpModal); - await lpModal.getByRole("textbox", { name: "Meter" }).click(); - const meterModal = page.getByTestId("meter-modal"); - await meterModal.getByRole("button", { name: "Delete" }).click(); - await expectModalHidden(meterModal); - - // restart without saving loadpoint - await restart(CONFIG_EMPTY); - await page.reload(); - - // check loadpoint default vehicle - await expect(page.getByTestId("loadpoint")).not.toContainText("11.0 kW"); - await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); - await expectModalVisible(lpModal); - await expect(lpModal.getByRole("textbox", { name: "Title" })).toHaveValue("Garage"); - await expect( - lpModal.getByRole("button", { name: "Add dedicated charger meter" }) - ).toBeVisible(); - }); - - test("user-defined charger", async ({ page }) => { - await start(CONFIG_EMPTY); - await page.goto("/#/config"); - await enableExperimental(page); - - // add loadpoint - await newLoadpoint(page, "Carport"); - const lpModal = page.getByTestId("loadpoint-modal"); - await lpModal.getByRole("button", { name: "Add charger" }).click(); - - // add user-defined charger - const chargerModal = page.getByTestId("charger-modal"); - await expectModalVisible(chargerModal); - await chargerModal.getByLabel("Manufacturer").selectOption("User-defined device"); - await page.waitForLoadState("networkidle"); - const editor = chargerModal.getByTestId("yaml-editor"); - await expect(editor).toContainText("status: # charger status [A..F]"); - - await editorClear(editor, 10); - await editorPaste( - editor, - page, - `status: - source: const - value: 'C' -enabled: - source: const - value: true -enable: - source: js - script: console.log(enable) -maxcurrent: - source: js - script: console.log(maxcurrent) -power: - source: const - value: 11000` - ); - - const restResult = chargerModal.getByTestId("test-result"); - await expect(restResult).toContainText("Status: unknown"); - await restResult.getByRole("link", { name: "validate" }).click(); - await expect(restResult).toContainText("Status: successful"); - await expect(restResult).toContainText(["Status", "charging"].join("")); - await expect(restResult).toContainText(["Enabled", "yes"].join("")); - await expect(restResult).toContainText(["Power", "11.0 kW"].join("")); - - // create - await chargerModal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(chargerModal); - await expectModalVisible(lpModal); - await lpModal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(lpModal); - - await expect(page.getByTestId("loadpoint")).toHaveCount(1); - await expect(page.getByTestId("loadpoint")).toContainText("Carport"); - - // restart evcc - await restart(CONFIG_EMPTY); - await page.reload(); - - const lpEntry = page.getByTestId("loadpoint"); - await expect(lpEntry).toHaveCount(1); - await expect(lpEntry).toContainText("Carport"); - await expect(lpEntry).toContainText(["Status", "charging"].join("")); - await expect(lpEntry).toContainText(["Enabled", "yes"].join("")); - await expect(lpEntry).toContainText(["Power", "11.0 kW"].join("")); - - await lpEntry.getByRole("button", { name: "edit" }).click(); - await expectModalVisible(lpModal); - - await expect(lpModal.getByLabel("Charger").first()).toHaveValue("User-defined device [db:1]"); - await lpModal.getByLabel("Charger").first().click(); - await expectModalVisible(chargerModal); - - await expect(chargerModal.getByLabel("Manufacturer")).toHaveValue("User-defined device"); - await page.waitForLoadState("networkidle"); - await expect(editor).toContainText("value: 'C'"); - await expect(editor).toContainText("value: 11000"); - }); -}); diff --git a/tests/config-loadpoint.spec.ts b/tests/config-loadpoint.spec.ts new file mode 100644 index 000000000..81cc4883e --- /dev/null +++ b/tests/config-loadpoint.spec.ts @@ -0,0 +1,512 @@ +import { test, expect, type Page } from "@playwright/test"; +import { start, stop, restart, baseUrl } from "./evcc"; +import { expectModalVisible, expectModalHidden, editorClear, editorPaste } from "./utils"; + +const CONFIG_EMPTY = "config-empty.evcc.yaml"; +const CONFIG_ONE_LP = "config-one-lp.evcc.yaml"; + +test.use({ baseURL: baseUrl() }); + +test.afterEach(async () => { + await stop(); +}); + +async function enableExperimental(page: Page) { + await page + .getByTestId("generalconfig-experimental") + .getByRole("button", { name: "edit" }) + .click(); + await page.getByLabel("Experimental 🧪").click(); + await page.getByRole("button", { name: "Close" }).click(); +} + +async function addDemoCharger(page: Page) { + const lpModal = page.getByTestId("loadpoint-modal"); + await lpModal.getByRole("button", { name: "Add charger" }).click(); + + const modal = page.getByTestId("charger-modal"); + await expectModalVisible(modal); + await modal.getByLabel("Manufacturer").selectOption("Demo charger"); + await modal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(modal); + await expectModalVisible(lpModal); +} + +async function addDemoMeter(page: Page, power = "0") { + const lpModal = page.getByTestId("loadpoint-modal"); + await lpModal.getByRole("button", { name: "Add dedicated charger meter" }).click(); + + const modal = page.getByTestId("meter-modal"); + await expectModalVisible(modal); + await modal.getByLabel("Manufacturer").selectOption("Demo meter"); + await modal.getByLabel("Power").fill(power); + await modal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(modal); + await expectModalVisible(lpModal); +} + +async function addVehicle(page: Page, title: string) { + await page.getByRole("button", { name: "Add vehicle" }).click(); + const modal = page.getByTestId("vehicle-modal"); + await expectModalVisible(modal); + await modal.getByLabel("Manufacturer").selectOption("Generic vehicle (without API)"); + await modal.getByLabel("Title").fill(title); + await modal.getByRole("button", { name: "Validate & save" }).click(); + await expectModalHidden(modal); +} + +async function newLoadpoint(page: Page, title: string) { + const lpModal = page.getByTestId("loadpoint-modal"); + await page.getByRole("button", { name: "Add charge point" }).click(); + await expectModalVisible(lpModal); + await lpModal.getByLabel("Title").fill(title); +} + +test.describe("loadpoint", async () => { + test("create, update and delete", async ({ page }) => { + await start(CONFIG_EMPTY); + + await page.goto("/#/config"); + await enableExperimental(page); + + const lpModal = page.getByTestId("loadpoint-modal"); + const chargerModal = page.getByTestId("charger-modal"); + + await expect(page.getByTestId("loadpoint")).toHaveCount(0); + + // new loadpoint + await newLoadpoint(page, "Solar Carport"); + await lpModal.getByRole("button", { name: "Add charger" }).click(); + + // add charger + await chargerModal.getByLabel("Manufacturer").selectOption("Demo charger"); + await chargerModal.getByLabel("Charge status").selectOption("C"); + await chargerModal.getByLabel("Power").fill("11000"); + await chargerModal.getByRole("button", { name: "Save" }).click(); + await expect(lpModal.getByLabel("Title")).toHaveValue("Solar Carport"); + + // create loadpoint + await lpModal.getByRole("button", { name: "Save" }).click(); + await expect(page.getByTestId("loadpoint")).toHaveCount(1); + await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport"); + await expect(page.getByTestId("loadpoint")).toContainText("charging"); + await expect(page.getByTestId("loadpoint")).toContainText("11.0 kW"); + + // restart button appears + const restartButton = await page + .getByTestId("bottom-banner") + .getByRole("button", { name: "Restart" }); + await expect(restartButton).toBeVisible(); + + // restart + await restart(CONFIG_EMPTY); + await page.reload(); + await expect(page.getByTestId("loadpoint")).toHaveCount(1); + await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport"); + + // update loadpoint title + await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + await lpModal.getByLabel("Title").fill("Solar Carport 2"); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2"); + + // restart + await restart(CONFIG_EMPTY); + await page.reload(); + await expect(page.getByTestId("loadpoint")).toHaveCount(1); + await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2"); + + // update loadpoint power + await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + await lpModal.getByTestId("chargerPower-22kw").click(); + + // update charger mode + await expect(lpModal.getByRole("textbox", { name: "Charger" })).toHaveValue( + "Demo charger [db:1]" + ); + await lpModal.getByRole("textbox", { name: "Charger" }).click(); + await chargerModal.getByLabel("Charge status").selectOption("A"); + await chargerModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(chargerModal); + + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + + // restart + await restart(CONFIG_EMPTY); + await page.reload(); + await expect(page.getByTestId("loadpoint")).toHaveCount(1); + await expect(page.getByTestId("loadpoint")).toContainText("not connected"); + + await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + await expect(lpModal.getByTestId("chargerPower-22kw")).toHaveClass(/active/); + await expect(lpModal.getByLabel("Title")).toHaveValue("Solar Carport 2"); + await lpModal.getByRole("button", { name: "Close" }).click(); + + // delete loadpoint + await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + await lpModal.getByRole("button", { name: "Delete" }).click(); + await expect(page.getByTestId("loadpoint")).toHaveCount(0); + + // restart + await restart(CONFIG_EMPTY); + await page.reload(); + await expect(page.getByTestId("loadpoint")).toHaveCount(0); + }); + + test("priority", async ({ page }) => { + await start(CONFIG_ONE_LP); + await page.goto("/#/config"); + await enableExperimental(page); + + const lpModal = page.getByTestId("loadpoint-modal"); + + // loadpoint from yaml + await expect(page.getByTestId("loadpoint")).toHaveCount(1); + await expect(page.getByTestId("loadpoint")).toContainText("Carport"); + + // add loadpoint via UI + await newLoadpoint(page, "Garage"); + await addDemoCharger(page); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + + // two loadpoints + await expect(page.getByTestId("loadpoint")).toHaveCount(2); + await expect(page.getByTestId("loadpoint").nth(0)).toContainText("Carport"); + await expect(page.getByTestId("loadpoint").nth(1)).toContainText("Garage"); + + // second loadpoint: increase priority + await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + await expect(lpModal.getByLabel("Priority")).toHaveValue("0 (default)"); + await lpModal.getByLabel("Priority").selectOption("1"); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + + // restart + await restart(CONFIG_ONE_LP); + await page.reload(); + + // check priorities + await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + await expect(lpModal.getByLabel("Priority")).toHaveValue("1"); + + // change back to 0 + await lpModal.getByLabel("Priority").selectOption("0 (default)"); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + + // restart + await restart(CONFIG_ONE_LP); + await page.reload(); + + // check priorities + await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + await expect(lpModal.getByLabel("Priority")).toHaveValue("0 (default)"); + }); + + test("vehicle", async ({ page }) => { + await start(CONFIG_EMPTY); + await page.goto("/#/config"); + await enableExperimental(page); + + const LP_1 = "Carport"; + const LP_2 = "Garage"; + const VEHICLE_1 = "Green Car"; + const VEHICLE_2 = "Yellow Van"; + + const lpModal = page.getByTestId("loadpoint-modal"); + + // add loadpoint > no vehicle option + await newLoadpoint(page, LP_1); + await addDemoCharger(page); + await expect(lpModal.getByLabel("Default vehicle")).not.toBeVisible(); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + + // edit loadpoint + await page.getByTestId("loadpoint").nth(0).getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + await expect(lpModal).toContainText("No vehicles are configured."); + await lpModal.getByRole("button", { name: "Close" }).click(); + + // add vehicles + for (const title of [VEHICLE_1, VEHICLE_2]) { + await addVehicle(page, title); + } + + // set vehicle as default for loadpoint 1 + await page.getByTestId("loadpoint").nth(0).getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + await expect(lpModal.getByTestId("loadpointPollMode-charging")).toHaveClass(/active/); + await expect( + lpModal.getByRole("checkbox", { name: "Interpolate charge level" }) + ).toBeChecked(); + await lpModal.getByLabel("Default vehicle").selectOption(VEHICLE_1); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + + // add second loadpoint + await newLoadpoint(page, LP_2); + await addDemoCharger(page); + await lpModal.getByLabel("Default vehicle").selectOption(VEHICLE_2); + await lpModal.getByRole("button", { name: "Save" }).click(); + + // restart + await restart(CONFIG_EMPTY); + await page.reload(); + + // check loadpoint default vehicles + for (const [index, vehicle] of [VEHICLE_1, VEHICLE_2].entries()) { + await page + .getByTestId("loadpoint") + .nth(index) + .getByRole("button", { name: "edit" }) + .click(); + await expectModalVisible(lpModal); + await expect(lpModal.locator("#loadpointParamVehicle option:checked")).toHaveText( + vehicle + ); + await lpModal.getByRole("button", { name: "Close" }).click(); + await expectModalHidden(lpModal); + } + }); + + test("keep mode", async ({ page }) => { + await start(CONFIG_EMPTY); + await page.goto("/#/config"); + await enableExperimental(page); + + // add grid meter + await page.getByRole("button", { name: "Add grid meter" }).click(); + const meterModal = page.getByTestId("meter-modal"); + await meterModal.getByLabel("Manufacturer").selectOption("Demo meter"); + await meterModal.getByLabel("Power").fill("-1000"); + await meterModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(meterModal); + + // add a loadpoint with dummy charger, + const lpModal = page.getByTestId("loadpoint-modal"); + await newLoadpoint(page, "Carport"); + await addDemoCharger(page); + await lpModal.getByLabel("Default mode").selectOption("---"); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + await restart(CONFIG_EMPTY); + + // change on main ui + await page.goto("/"); + await expect(page.getByRole("button", { name: "Off" })).toHaveClass(/active/); + await page.getByRole("button", { name: "Solar", exact: true }).click(); + await restart(CONFIG_EMPTY); + await page.reload(); + await expect(page.getByRole("button", { name: "Solar", exact: true })).toHaveClass( + /active/ + ); + + // change default mode in config to fast + await page.goto("/#/config"); + // open first loadpoint + await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + await lpModal.getByLabel("Default mode").selectOption("Fast"); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + await restart(CONFIG_EMPTY); + + // check loadpoint mode + await page.goto("/"); + await expect(page.getByRole("button", { name: "Fast" })).toHaveClass(/active/); + }); + + test("delete vehicle references", async ({ page }) => { + await start(CONFIG_EMPTY); + await page.goto("/#/config"); + await enableExperimental(page); + + // add vehicle, add loadpoint, add charger + await addVehicle(page, "Porsche"); + await addVehicle(page, "Tesla"); + await newLoadpoint(page, "Garage"); + await addDemoCharger(page); + const lpModal = page.getByTestId("loadpoint-modal"); + await expectModalVisible(lpModal); + await lpModal.getByLabel("Default vehicle").selectOption("Porsche"); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + + // delete vehicle + await page.getByTestId("vehicle").nth(0).getByRole("button", { name: "edit" }).click(); + const vehicleModal = page.getByTestId("vehicle-modal"); + await expectModalVisible(vehicleModal); + await vehicleModal.getByRole("button", { name: "Delete" }).click(); + await expectModalHidden(vehicleModal); + + // restart + await restart(CONFIG_EMPTY); + await page.reload(); + + // check loadpoint default vehicle + await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + await expect(lpModal.getByLabel("Default vehicle")).toHaveValue(""); + }); + + test("delete charger references", async ({ page }) => { + await start(CONFIG_EMPTY); + await page.goto("/#/config"); + await enableExperimental(page); + + // add loadpoint, add charger + await newLoadpoint(page, "Garage"); + await addDemoCharger(page); + const lpModal = page.getByTestId("loadpoint-modal"); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + + // delete charger + await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + await lpModal.getByRole("textbox", { name: "Charger" }).click(); + const chargerModal = page.getByTestId("charger-modal"); + await expectModalVisible(chargerModal); + await chargerModal.getByRole("button", { name: "Delete" }).click(); + await expectModalHidden(chargerModal); + + // restart without saving loadpoint + await restart(CONFIG_EMPTY); + await page.reload(); + + // check loadpoint default vehicle + await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + await expect(lpModal.getByRole("textbox", { name: "Title" })).toHaveValue("Garage"); + await expect(lpModal).toContainText("Configuring a charger is required."); + }); + + test("delete meter references", async ({ page }) => { + await start(CONFIG_EMPTY); + await page.goto("/#/config"); + await enableExperimental(page); + + // add loadpoint, add charger + await newLoadpoint(page, "Garage"); + await addDemoCharger(page); + await addDemoMeter(page, "11000"); + const lpModal = page.getByTestId("loadpoint-modal"); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + await expect(page.getByTestId("loadpoint")).toContainText("11.0 kW"); + + // delete charger + await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + await lpModal.getByRole("textbox", { name: "Meter" }).click(); + const meterModal = page.getByTestId("meter-modal"); + await meterModal.getByRole("button", { name: "Delete" }).click(); + await expectModalHidden(meterModal); + + // restart without saving loadpoint + await restart(CONFIG_EMPTY); + await page.reload(); + + // check loadpoint default vehicle + await expect(page.getByTestId("loadpoint")).not.toContainText("11.0 kW"); + await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + await expect(lpModal.getByRole("textbox", { name: "Title" })).toHaveValue("Garage"); + await expect( + lpModal.getByRole("button", { name: "Add dedicated charger meter" }) + ).toBeVisible(); + }); + + test("user-defined charger", async ({ page }) => { + await start(CONFIG_EMPTY); + await page.goto("/#/config"); + await enableExperimental(page); + + // add loadpoint + await newLoadpoint(page, "Carport"); + const lpModal = page.getByTestId("loadpoint-modal"); + await lpModal.getByRole("button", { name: "Add charger" }).click(); + + // add user-defined charger + const chargerModal = page.getByTestId("charger-modal"); + await expectModalVisible(chargerModal); + await chargerModal.getByLabel("Manufacturer").selectOption("User-defined device"); + await page.waitForLoadState("networkidle"); + const editor = chargerModal.getByTestId("yaml-editor"); + await expect(editor).toContainText("status: # charger status [A..F]"); + + await editorClear(editor, 10); + await editorPaste( + editor, + page, + `status: + source: const + value: 'C' +enabled: + source: const + value: true +enable: + source: js + script: console.log(enable) +maxcurrent: + source: js + script: console.log(maxcurrent) +power: + source: const + value: 11000` + ); + + const restResult = chargerModal.getByTestId("test-result"); + await expect(restResult).toContainText("Status: unknown"); + await restResult.getByRole("link", { name: "validate" }).click(); + await expect(restResult).toContainText("Status: successful"); + await expect(restResult).toContainText(["Status", "charging"].join("")); + await expect(restResult).toContainText(["Enabled", "yes"].join("")); + await expect(restResult).toContainText(["Power", "11.0 kW"].join("")); + + // create + await chargerModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(chargerModal); + await expectModalVisible(lpModal); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + + await expect(page.getByTestId("loadpoint")).toHaveCount(1); + await expect(page.getByTestId("loadpoint")).toContainText("Carport"); + + // restart evcc + await restart(CONFIG_EMPTY); + await page.reload(); + + const lpEntry = page.getByTestId("loadpoint"); + await expect(lpEntry).toHaveCount(1); + await expect(lpEntry).toContainText("Carport"); + await expect(lpEntry).toContainText(["Status", "charging"].join("")); + await expect(lpEntry).toContainText(["Enabled", "yes"].join("")); + await expect(lpEntry).toContainText(["Power", "11.0 kW"].join("")); + + await lpEntry.getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + + await expect(lpModal.getByLabel("Charger").first()).toHaveValue( + "User-defined device [db:1]" + ); + await lpModal.getByLabel("Charger").first().click(); + await expectModalVisible(chargerModal); + + await expect(chargerModal.getByLabel("Manufacturer")).toHaveValue("User-defined device"); + await page.waitForLoadState("networkidle"); + await expect(editor).toContainText("value: 'C'"); + await expect(editor).toContainText("value: 11000"); + }); +}); diff --git a/tests/config-messaging.spec.js b/tests/config-messaging.spec.js deleted file mode 100644 index c110a83d5..000000000 --- a/tests/config-messaging.spec.js +++ /dev/null @@ -1,44 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { start, stop, baseUrl } from "./evcc"; -import { enableExperimental, expectModalHidden, expectModalVisible } from "./utils"; - -const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml"; - -test.use({ baseURL: baseUrl() }); - -test.afterEach(async () => { - await stop(); -}); - -const SELECT_ALL = "ControlOrMeta+KeyA"; - -async function goToConfig(page) { - await page.goto("/#/config"); - await enableExperimental(page); -} - -test.describe("messaging", async () => { - test("save a comment", async ({ page }) => { - await start(CONFIG_GRID_ONLY); - await goToConfig(page); - - await page.getByTestId("messaging").getByRole("button", { name: "edit" }).click(); - const modal = await page.getByTestId("messaging-modal"); - await expectModalVisible(modal); - - await modal.locator(".monaco-editor .view-line").nth(0).click(); - for (let i = 0; i < 4; i++) { - await page.keyboard.press(SELECT_ALL, { delay: 10 }); - await page.keyboard.press("Backspace", { delay: 10 }); - } - await page.keyboard.type("# hello world"); - await page.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(modal); - - page.reload(); - - await page.getByTestId("messaging").getByRole("button", { name: "edit" }).click(); - await expectModalVisible(modal); - await expect(modal).toContainText("# hello world"); - }); -}); diff --git a/tests/config-messaging.spec.ts b/tests/config-messaging.spec.ts new file mode 100644 index 000000000..05ef0c74e --- /dev/null +++ b/tests/config-messaging.spec.ts @@ -0,0 +1,44 @@ +import { test, expect, type Page } from "@playwright/test"; +import { start, stop, baseUrl } from "./evcc"; +import { enableExperimental, expectModalHidden, expectModalVisible } from "./utils"; + +const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml"; + +test.use({ baseURL: baseUrl() }); + +test.afterEach(async () => { + await stop(); +}); + +const SELECT_ALL = "ControlOrMeta+KeyA"; + +async function goToConfig(page: Page) { + await page.goto("/#/config"); + await enableExperimental(page); +} + +test.describe("messaging", async () => { + test("save a comment", async ({ page }) => { + await start(CONFIG_GRID_ONLY); + await goToConfig(page); + + await page.getByTestId("messaging").getByRole("button", { name: "edit" }).click(); + const modal = await page.getByTestId("messaging-modal"); + await expectModalVisible(modal); + + await modal.locator(".monaco-editor .view-line").nth(0).click(); + for (let i = 0; i < 4; i++) { + await page.keyboard.press(SELECT_ALL, { delay: 10 }); + await page.keyboard.press("Backspace", { delay: 10 }); + } + await page.keyboard.type("# hello world"); + await page.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(modal); + + page.reload(); + + await page.getByTestId("messaging").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(modal); + await expect(modal).toContainText("# hello world"); + }); +}); diff --git a/tests/config-mqtt.spec.js b/tests/config-mqtt.spec.ts similarity index 95% rename from tests/config-mqtt.spec.js rename to tests/config-mqtt.spec.ts index d17180d01..bebff86c6 100644 --- a/tests/config-mqtt.spec.js +++ b/tests/config-mqtt.spec.ts @@ -26,8 +26,12 @@ const VALID_USERNAME = "rw"; const VALID_PASSWORD = "readwrite"; test.describe("mqtt", async () => { + let mqttReachable = true; + test.beforeAll(async () => { + mqttReachable = await isMqttReachable(VALID_BROKER, VALID_USERNAME, VALID_PASSWORD); + }); test.skip( - async () => !(await isMqttReachable(VALID_BROKER, VALID_USERNAME, VALID_PASSWORD)), + !mqttReachable, `MQTT broker ${VALID_BROKER} is not reachable, skipping tests` ); diff --git a/tests/config-onboarding.spec.js b/tests/config-onboarding.spec.ts similarity index 100% rename from tests/config-onboarding.spec.js rename to tests/config-onboarding.spec.ts diff --git a/tests/config-pv.spec.js b/tests/config-pv.spec.js deleted file mode 100644 index 1f079afdc..000000000 --- a/tests/config-pv.spec.js +++ /dev/null @@ -1,114 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { start, stop, restart, baseUrl } from "./evcc"; -import { startSimulator, stopSimulator, simulatorHost } from "./simulator"; -import { enableExperimental, expectModalHidden, expectModalVisible } from "./utils"; - -const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml"; - -test.use({ baseURL: baseUrl() }); - -test.beforeAll(async () => { - await start(CONFIG_GRID_ONLY); -}); -test.afterAll(async () => { - await stop(); -}); - -test.describe("pv meter", async () => { - test("create, edit and remove pv meter", async ({ page }) => { - await page.goto("/#/config"); - await enableExperimental(page); - - await expect(page.getByTestId("pv")).toHaveCount(0); - - // create #1 - await page.getByRole("button", { name: "Add solar or battery" }).click(); - - const meterModal = page.getByTestId("meter-modal"); - await meterModal.getByRole("button", { name: "Add solar meter" }).click(); - await meterModal.getByLabel("Title").fill("PV North"); - await meterModal.getByLabel("Manufacturer").selectOption("Demo meter"); - await meterModal.getByLabel("Power optional").fill("5000"); - await page.getByRole("button", { name: "Show advanced settings" }).click(); - await expect(meterModal.getByLabel("Minimum charge")).not.toBeVisible(); // battery usage only - await expect(meterModal.getByLabel("Maximum AC power of the hybrid inverter")).toBeVisible(); // pv usage only - await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible(); - await meterModal.getByRole("link", { name: "validate" }).click(); - await expect(meterModal.getByTestId("device-tag-power")).toContainText("5.0 kW"); - await meterModal.getByRole("button", { name: "Save" }).click(); - await expectModalHidden(meterModal); - await expect(page.getByTestId("pv")).toBeVisible(1); - await expect(page.getByTestId("pv")).toContainText("PV North"); - - // edit #1 - await page.getByTestId("pv").getByRole("button", { name: "edit" }).click(); - await expectModalVisible(meterModal); - await meterModal.getByLabel("Power optional").fill("6000"); - await meterModal.getByRole("button", { name: "Validate & save" }).click(); - await expectModalHidden(meterModal); - - const pv = page.getByTestId("pv"); - await expect(pv).toBeVisible(1); - await expect(pv).toContainText("PV North"); - await expect(pv.getByTestId("device-tag-power")).toContainText("6.0 kW"); - - // restart and check in main ui - await restart(CONFIG_GRID_ONLY); - await page.goto("/"); - await page.getByTestId("visualization").click(); - await expect(page.getByTestId("energyflow")).toContainText("Production6.0 kW"); - - // delete #1 - await page.goto("/#/config"); - await page.getByTestId("pv").getByRole("button", { name: "edit" }).click(); - await expectModalVisible(meterModal); - await meterModal.getByRole("button", { name: "Delete" }).click(); - await expectModalHidden(meterModal); - await expect(page.getByTestId("pv")).toHaveCount(0); - - // restart and check again - await restart(CONFIG_GRID_ONLY); - await page.reload(); - await expect(page.getByTestId("pv")).toHaveCount(0); - }); - - test("remove broken pv meter", async ({ page }) => { - // setup test data for mock openems api - await startSimulator(); - - await page.goto("/#/config"); - await enableExperimental(page); - - // create meter - await page.getByRole("button", { name: "Add solar or battery" }).click(); - const meterModal = page.getByTestId("meter-modal"); - await meterModal.getByRole("button", { name: "Add solar meter" }).click(); - await meterModal.getByLabel("Title").fill("North Roof"); - await meterModal.getByLabel("Manufacturer").selectOption("shelly-1pm"); - await meterModal.getByLabel("IP address or hostname").fill(simulatorHost()); - await meterModal.getByRole("button", { name: "Validate & save" }).click(); - await expectModalHidden(meterModal); - await expect(page.getByTestId("pv")).toBeVisible(1); - await expect(page.getByTestId("pv")).toContainText("North Roof"); - - // break meter - await stopSimulator(); - await restart(CONFIG_GRID_ONLY); - await page.reload(); - - // remove meter - await expect(page.getByTestId("fatal-error")).toBeVisible(); - await expect(page.getByTestId("pv")).toBeVisible(1); - await page.getByTestId("pv").getByRole("button", { name: "edit" }).click(); - await expectModalVisible(meterModal); - await meterModal.getByRole("button", { name: "Delete" }).click(); - await expectModalHidden(meterModal); - await expect(page.getByTestId("pv")).toHaveCount(0); - - // restart and check again - await restart(CONFIG_GRID_ONLY); - await page.reload(); - await expect(page.getByTestId("pv")).toHaveCount(0); - await expect(page.getByTestId("fatal-error")).not.toBeVisible(); - }); -}); diff --git a/tests/config-pv.spec.ts b/tests/config-pv.spec.ts new file mode 100644 index 000000000..2be2cb3d2 --- /dev/null +++ b/tests/config-pv.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, restart, baseUrl } from "./evcc"; +import { startSimulator, stopSimulator, simulatorHost } from "./simulator"; +import { enableExperimental, expectModalHidden, expectModalVisible } from "./utils"; + +const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml"; + +test.use({ baseURL: baseUrl() }); + +test.beforeAll(async () => { + await start(CONFIG_GRID_ONLY); +}); +test.afterAll(async () => { + await stop(); +}); + +test.describe("pv meter", async () => { + test("create, edit and remove pv meter", async ({ page }) => { + await page.goto("/#/config"); + await enableExperimental(page); + + await expect(page.getByTestId("pv")).toHaveCount(0); + + // create #1 + await page.getByRole("button", { name: "Add solar or battery" }).click(); + + const meterModal = page.getByTestId("meter-modal"); + await meterModal.getByRole("button", { name: "Add solar meter" }).click(); + await meterModal.getByLabel("Title").fill("PV North"); + await meterModal.getByLabel("Manufacturer").selectOption("Demo meter"); + await meterModal.getByLabel("Power optional").fill("5000"); + await page.getByRole("button", { name: "Show advanced settings" }).click(); + await expect(meterModal.getByLabel("Minimum charge")).not.toBeVisible(); // battery usage only + await expect( + meterModal.getByLabel("Maximum AC power of the hybrid inverter") + ).toBeVisible(); // pv usage only + await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible(); + await meterModal.getByRole("link", { name: "validate" }).click(); + await expect(meterModal.getByTestId("device-tag-power")).toContainText("5.0 kW"); + await meterModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(meterModal); + await expect(page.getByTestId("pv")).toBeVisible(); + await expect(page.getByTestId("pv")).toContainText("PV North"); + + // edit #1 + await page.getByTestId("pv").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(meterModal); + await meterModal.getByLabel("Power optional").fill("6000"); + await meterModal.getByRole("button", { name: "Validate & save" }).click(); + await expectModalHidden(meterModal); + + const pv = page.getByTestId("pv"); + await expect(pv).toBeVisible(); + await expect(pv).toContainText("PV North"); + await expect(pv.getByTestId("device-tag-power")).toContainText("6.0 kW"); + + // restart and check in main ui + await restart(CONFIG_GRID_ONLY); + await page.goto("/"); + await page.getByTestId("visualization").click(); + await expect(page.getByTestId("energyflow")).toContainText("Production6.0 kW"); + + // delete #1 + await page.goto("/#/config"); + await page.getByTestId("pv").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(meterModal); + await meterModal.getByRole("button", { name: "Delete" }).click(); + await expectModalHidden(meterModal); + await expect(page.getByTestId("pv")).toHaveCount(0); + + // restart and check again + await restart(CONFIG_GRID_ONLY); + await page.reload(); + await expect(page.getByTestId("pv")).toHaveCount(0); + }); + + test("remove broken pv meter", async ({ page }) => { + // setup test data for mock openems api + await startSimulator(); + + await page.goto("/#/config"); + await enableExperimental(page); + + // create meter + await page.getByRole("button", { name: "Add solar or battery" }).click(); + const meterModal = page.getByTestId("meter-modal"); + await meterModal.getByRole("button", { name: "Add solar meter" }).click(); + await meterModal.getByLabel("Title").fill("North Roof"); + await meterModal.getByLabel("Manufacturer").selectOption("shelly-1pm"); + await meterModal.getByLabel("IP address or hostname").fill(simulatorHost()); + await meterModal.getByRole("button", { name: "Validate & save" }).click(); + await expectModalHidden(meterModal); + await expect(page.getByTestId("pv")).toBeVisible(); + await expect(page.getByTestId("pv")).toContainText("North Roof"); + + // break meter + await stopSimulator(); + await restart(CONFIG_GRID_ONLY); + await page.reload(); + + // remove meter + await expect(page.getByTestId("fatal-error")).toBeVisible(); + await expect(page.getByTestId("pv")).toBeVisible(); + await page.getByTestId("pv").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(meterModal); + await meterModal.getByRole("button", { name: "Delete" }).click(); + await expectModalHidden(meterModal); + await expect(page.getByTestId("pv")).toHaveCount(0); + + // restart and check again + await restart(CONFIG_GRID_ONLY); + await page.reload(); + await expect(page.getByTestId("pv")).toHaveCount(0); + await expect(page.getByTestId("fatal-error")).not.toBeVisible(); + }); +}); diff --git a/tests/config-tariffs.spec.js b/tests/config-tariffs.spec.js deleted file mode 100644 index aaf72b1e0..000000000 --- a/tests/config-tariffs.spec.js +++ /dev/null @@ -1,106 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { start, stop, restart, baseUrl } from "./evcc"; -import { - enableExperimental, - expectModalHidden, - expectModalVisible, - editorClear, - editorPaste, -} from "./utils"; - -const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml"; -const CONFIG_WITH_TARIFFS = "config-with-tariffs.evcc.yaml"; - -test.use({ baseURL: baseUrl() }); -test.describe.configure({ mode: "parallel" }); - -test.afterEach(async () => { - await stop(); -}); - -async function goToConfig(page) { - await page.goto("/#/config"); - await enableExperimental(page); -} - -test.describe("tariffs", async () => { - test("tariffs not configured", async ({ page }) => { - await start(CONFIG_GRID_ONLY); - await goToConfig(page); - - await expect(page.getByTestId("tariffs")).not.toBeVisible(); - await expect(page.getByTestId("add-tariffs")).toBeVisible(); - }); - - test("tariffs via ui", async ({ page }) => { - await start(CONFIG_GRID_ONLY); - await goToConfig(page); - - await page.getByTestId("add-tariffs").click(); - const modal = await page.getByTestId("tariffs-modal"); - await expectModalVisible(modal); - await page.waitForLoadState("networkidle"); - - // default content - const editor = modal.getByTestId("yaml-editor"); - await expect(editor).toContainText("#currency: EUR"); - - // clear and enter invalid yaml - await editorClear(editor); - await editorPaste(editor, page, "foo: bar"); - await page.getByRole("button", { name: "Save" }).click(); - await expect(modal.getByTestId("error")).toContainText("invalid keys: foo"); - - // clear and enter valid yaml - await editorClear(editor); - await editorPaste( - editor, - page, - `currency: CHF -grid: - type: fixed - price: 0.123` - ); - - await page.getByRole("button", { name: "Save" }).click(); - await expect(modal.getByTestId("error")).not.toBeVisible(); - - // modal closes - await expectModalHidden(modal); - - // restart button appears - const restartButton = await page - .getByTestId("bottom-banner") - .getByRole("button", { name: "Restart" }); - await expect(restartButton).toBeVisible(); - - await restart(CONFIG_GRID_ONLY); - - // restart done - await expect(restartButton).not.toBeVisible(); - - await expect(page.getByTestId("tariffs")).toContainText( - ["Currency", "CHF", "Grid price", "12.3 rp"].join("") - ); - }); - - test("tariffs from evcc.yaml", async ({ page }) => { - await start(CONFIG_WITH_TARIFFS); - await goToConfig(page); - - await expect(page.getByTestId("tariffs")).toBeVisible(); - await expect(page.getByTestId("tariffs")).toContainText( - [ - "Tariffs", - "Currency", - "SEK", - "Grid price", - "30.0 öre", - "Feed-in price", - "-10.0 öre", - "Grid CO₂", - "300 g", - ].join("") - ); - }); -}); diff --git a/tests/config-tariffs.spec.ts b/tests/config-tariffs.spec.ts new file mode 100644 index 000000000..0b767aa2d --- /dev/null +++ b/tests/config-tariffs.spec.ts @@ -0,0 +1,106 @@ +import { test, expect, type Page } from "@playwright/test"; +import { start, stop, restart, baseUrl } from "./evcc"; +import { + enableExperimental, + expectModalHidden, + expectModalVisible, + editorClear, + editorPaste, +} from "./utils"; + +const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml"; +const CONFIG_WITH_TARIFFS = "config-with-tariffs.evcc.yaml"; + +test.use({ baseURL: baseUrl() }); +test.describe.configure({ mode: "parallel" }); + +test.afterEach(async () => { + await stop(); +}); + +async function goToConfig(page: Page) { + await page.goto("/#/config"); + await enableExperimental(page); +} + +test.describe("tariffs", async () => { + test("tariffs not configured", async ({ page }) => { + await start(CONFIG_GRID_ONLY); + await goToConfig(page); + + await expect(page.getByTestId("tariffs")).not.toBeVisible(); + await expect(page.getByTestId("add-tariffs")).toBeVisible(); + }); + + test("tariffs via ui", async ({ page }) => { + await start(CONFIG_GRID_ONLY); + await goToConfig(page); + + await page.getByTestId("add-tariffs").click(); + const modal = await page.getByTestId("tariffs-modal"); + await expectModalVisible(modal); + await page.waitForLoadState("networkidle"); + + // default content + const editor = modal.getByTestId("yaml-editor"); + await expect(editor).toContainText("#currency: EUR"); + + // clear and enter invalid yaml + await editorClear(editor); + await editorPaste(editor, page, "foo: bar"); + await page.getByRole("button", { name: "Save" }).click(); + await expect(modal.getByTestId("error")).toContainText("invalid keys: foo"); + + // clear and enter valid yaml + await editorClear(editor); + await editorPaste( + editor, + page, + `currency: CHF +grid: + type: fixed + price: 0.123` + ); + + await page.getByRole("button", { name: "Save" }).click(); + await expect(modal.getByTestId("error")).not.toBeVisible(); + + // modal closes + await expectModalHidden(modal); + + // restart button appears + const restartButton = await page + .getByTestId("bottom-banner") + .getByRole("button", { name: "Restart" }); + await expect(restartButton).toBeVisible(); + + await restart(CONFIG_GRID_ONLY); + + // restart done + await expect(restartButton).not.toBeVisible(); + + await expect(page.getByTestId("tariffs")).toContainText( + ["Currency", "CHF", "Grid price", "12.3 rp"].join("") + ); + }); + + test("tariffs from evcc.yaml", async ({ page }) => { + await start(CONFIG_WITH_TARIFFS); + await goToConfig(page); + + await expect(page.getByTestId("tariffs")).toBeVisible(); + await expect(page.getByTestId("tariffs")).toContainText( + [ + "Tariffs", + "Currency", + "SEK", + "Grid price", + "30.0 öre", + "Feed-in price", + "-10.0 öre", + "Grid CO₂", + "300 g", + ].join("") + ); + }); +}); diff --git a/tests/config-vehicles.spec.js b/tests/config-vehicles.spec.ts similarity index 100% rename from tests/config-vehicles.spec.js rename to tests/config-vehicles.spec.ts diff --git a/tests/config.spec.js b/tests/config.spec.ts similarity index 100% rename from tests/config.spec.js rename to tests/config.spec.ts diff --git a/tests/custom-css.spec.js b/tests/custom-css.spec.ts similarity index 100% rename from tests/custom-css.spec.js rename to tests/custom-css.spec.ts diff --git a/tests/evcc.js b/tests/evcc.js deleted file mode 100644 index da8423471..000000000 --- a/tests/evcc.js +++ /dev/null @@ -1,136 +0,0 @@ -import fs from "fs"; -import waitOn from "wait-on"; -import axios from "axios"; -import { spawn, execSync } from "child_process"; -import os from "os"; -import path from "path"; -import { Transform } from "stream"; - -const BINARY = "./evcc"; - -function workerPort() { - const index = process.env.TEST_WORKER_INDEX * 1; - return 11000 + index; -} - -function logPrefix() { - return `[worker:${process.env.TEST_WORKER_INDEX}]`; -} - -function createSteamLog() { - return new Transform({ - transform(chunk, encoding, callback) { - const lines = chunk.toString().split("\n"); - lines.forEach((line) => { - if (line.trim()) log(line); - }); - callback(); - }, - }); -} - -function log(...args) { - console.log(logPrefix(), ...args); -} - -export function baseUrl() { - return `http://localhost:${workerPort()}`; -} - -function dbPath() { - const file = `evcc-${workerPort()}.db`; - return path.join(os.tmpdir(), file); -} - -export async function start(config, sqlDumps, flags = "--disable-auth") { - await _clean(); - if (sqlDumps) { - await _restoreDatabase(sqlDumps); - } - return await _start(config, flags); -} - -export async function stop(instance) { - await _stop(instance); - await _clean(); -} - -export async function restart(config, flags = "--disable-auth") { - await _stop(); - await _start(config, flags); -} - -export async function cleanRestart(config, sqlDumps) { - await _stop(); - await _clean(); - if (sqlDumps) { - await _restoreDatabase(sqlDumps); - } - await _start(config); -} - -async function _restoreDatabase(sqlDumps) { - const dumps = Array.isArray(sqlDumps) ? sqlDumps : [sqlDumps]; - for (const dump of dumps) { - log("loading database", dbPath(), dump); - execSync(`sqlite3 ${dbPath()} < tests/${dump}`); - } -} - -async function _start(config, flags = []) { - const configFile = config.includes("/") ? config : `tests/${config}`; - const port = workerPort(); - log(`wait until port ${port} is available`); - // wait for port to be available - await waitOn({ resources: [`tcp:${port}`], reverse: true, log: true }); - const additionalFlags = typeof flags === "string" ? [flags] : flags; - additionalFlags.push("--log", "debug,httpd:trace"); - log("starting evcc", { config, port, additionalFlags }); - const instance = spawn(BINARY, ["--config", configFile, ...additionalFlags], { - env: { EVCC_NETWORK_PORT: port.toString(), EVCC_DATABASE_DSN: dbPath() }, - stdio: ["pipe", "pipe", "pipe"], - }); - const steamLog = createSteamLog(); - instance.stdout.pipe(steamLog); - instance.stderr.pipe(steamLog); - instance.on("exit", (code) => { - log("evcc terminated", { code, port, config }); - steamLog.end(); - }); - await waitOn({ resources: [baseUrl()], log: true }); - return instance; -} - -async function _stop(instance) { - const port = workerPort(); - if (instance) { - log("shutting down evcc hard", { port }); - instance.kill("SIGKILL"); - await waitOn({ resources: [`tcp:${port}`], reverse: true, log: true }); - log("evcc is down", { port }); - return; - } - // check if auth is required - const res = await axios.get(`${baseUrl()}/api/auth/status`); - log("auth status", res.status, res.statusText, res.data); - let cookie; - // login required - if (!res.data) { - const res = await axios.post(`${baseUrl()}/api/auth/login`, { password: "secret" }); - log("login", res.status, res.statusText); - cookie = res.headers["set-cookie"]; - } - log("shutting down evcc", { port }); - await axios.post(`${baseUrl()}/api/system/shutdown`, {}, { headers: { cookie } }); - log(`wait until port ${port} is closed`); - await waitOn({ resources: [`tcp:${port}`], reverse: true, log: true }); - log("evcc is down", { port }); -} - -async function _clean() { - const db = dbPath(); - if (fs.existsSync(db)) { - log("delete database", db); - fs.unlinkSync(db); - } -} diff --git a/tests/evcc.ts b/tests/evcc.ts new file mode 100644 index 000000000..d0cf15bc0 --- /dev/null +++ b/tests/evcc.ts @@ -0,0 +1,140 @@ +import fs from "fs"; +import waitOn from "wait-on"; +import axios from "axios"; +import { spawn, execSync, ChildProcess } from "child_process"; +import os from "os"; +import path from "path"; +import { Transform } from "stream"; + +const BINARY = "./evcc"; + +function workerPort() { + const index = Number(process.env["TEST_WORKER_INDEX"] ?? 0); + return 11000 + index; +} + +function logPrefix() { + return `[worker:${process.env["TEST_WORKER_INDEX"]}]`; +} + +function createSteamLog() { + return new Transform({ + transform(chunk: Buffer, _, callback) { + const lines = chunk.toString().split("\n"); + lines.forEach((line: string) => { + if (line.trim()) log(line); + }); + callback(); + }, + }); +} + +function log(...args: any[]) { + console.log(logPrefix(), ...args); +} + +export function baseUrl() { + return `http://localhost:${workerPort()}`; +} + +function dbPath() { + const file = `evcc-${workerPort()}.db`; + return path.join(os.tmpdir(), file); +} + +export async function start( + config: string, + sqlDumps?: string | null, + flags: string | string[] = "--disable-auth" +) { + await _clean(); + if (sqlDumps) { + await _restoreDatabase(sqlDumps); + } + return await _start(config, flags); +} + +export async function stop(instance?: ChildProcess) { + await _stop(instance); + await _clean(); +} + +export async function restart(config: string, flags = "--disable-auth") { + await _stop(); + await _start(config, flags); +} + +export async function cleanRestart(config: string, sqlDumps: string) { + await _stop(); + await _clean(); + if (sqlDumps) { + await _restoreDatabase(sqlDumps); + } + await _start(config); +} + +async function _restoreDatabase(sqlDumps: string) { + const dumps = Array.isArray(sqlDumps) ? sqlDumps : [sqlDumps]; + for (const dump of dumps) { + log("loading database", dbPath(), dump); + execSync(`sqlite3 ${dbPath()} < tests/${dump}`); + } +} + +async function _start(config: string, flags: string | string[] = []) { + const configFile = config.includes("/") ? config : `tests/${config}`; + const port = workerPort(); + log(`wait until port ${port} is available`); + // wait for port to be available + await waitOn({ resources: [`tcp:${port}`], reverse: true, log: true }); + const additionalFlags = typeof flags === "string" ? [flags] : flags; + additionalFlags.push("--log", "debug,httpd:trace"); + log("starting evcc", { config, port, additionalFlags }); + const instance = spawn(BINARY, ["--config", configFile, ...additionalFlags], { + env: { EVCC_NETWORK_PORT: port.toString(), EVCC_DATABASE_DSN: dbPath() }, + stdio: ["pipe", "pipe", "pipe"], + }); + const steamLog = createSteamLog(); + instance.stdout.pipe(steamLog); + instance.stderr.pipe(steamLog); + instance.on("exit", (code) => { + log("evcc terminated", { code, port, config }); + steamLog.end(); + }); + await waitOn({ resources: [baseUrl()], log: true }); + return instance; +} + +async function _stop(instance?: ChildProcess) { + const port = workerPort(); + if (instance) { + log("shutting down evcc hard", { port }); + instance.kill("SIGKILL"); + await waitOn({ resources: [`tcp:${port}`], reverse: true, log: true }); + log("evcc is down", { port }); + return; + } + // check if auth is required + const res = await axios.get(`${baseUrl()}/api/auth/status`); + log("auth status", res.status, res.statusText, res.data); + let cookie; + // login required + if (!res.data) { + const res = await axios.post(`${baseUrl()}/api/auth/login`, { password: "secret" }); + log("login", res.status, res.statusText); + cookie = res.headers["set-cookie"]; + } + log("shutting down evcc", { port }); + await axios.post(`${baseUrl()}/api/system/shutdown`, {}, { headers: { cookie } }); + log(`wait until port ${port} is closed`); + await waitOn({ resources: [`tcp:${port}`], reverse: true, log: true }); + log("evcc is down", { port }); +} + +async function _clean() { + const db = dbPath(); + if (fs.existsSync(db)) { + log("delete database", db); + fs.unlinkSync(db); + } +} diff --git a/tests/fatal.spec.js b/tests/fatal.spec.ts similarity index 100% rename from tests/fatal.spec.js rename to tests/fatal.spec.ts diff --git a/tests/heating.spec.js b/tests/heating.spec.ts similarity index 100% rename from tests/heating.spec.js rename to tests/heating.spec.ts diff --git a/tests/limits.spec.js b/tests/limits.spec.ts similarity index 100% rename from tests/limits.spec.js rename to tests/limits.spec.ts diff --git a/tests/logs.spec.js b/tests/logs.spec.ts similarity index 100% rename from tests/logs.spec.js rename to tests/logs.spec.ts diff --git a/tests/modals.spec.js b/tests/modals.spec.ts similarity index 100% rename from tests/modals.spec.js rename to tests/modals.spec.ts diff --git a/tests/plan.spec.js b/tests/plan.spec.js deleted file mode 100644 index 94de8e99f..000000000 --- a/tests/plan.spec.js +++ /dev/null @@ -1,630 +0,0 @@ -import { test, expect, devices } from "@playwright/test"; -import { start, stop, baseUrl, restart } from "./evcc"; - -test.use({ baseURL: baseUrl() }); -test.describe.configure({ mode: "parallel" }); - -const mobile = devices["iPhone 12 Mini"].viewport; - -const CONFIG = "plan.evcc.yaml"; -const CONFIG_NO_TARIFF = "basics.evcc.yaml"; - -test.beforeEach(async () => { - await start(CONFIG); -}); - -test.afterEach(async () => { - await stop(); -}); - -function getWeekday(offset = 1) { - const date = new Date(); - date.setDate(date.getDate() + offset); - return date.toLocaleDateString("en-US", { weekday: "long" }); -} - -async function setAndVerifyPlan(page, lp, { soc, energy }) { - await lp.getByTestId("charging-plan-button").click(); - - if (soc) { - await page.getByTestId("static-plan-soc").selectOption(soc); - } - if (energy) { - // select "25 kWh (+50%)" by providing "25 kWh" as option text - const optionText = await page - .getByTestId("static-plan-energy") - .locator("option", { hasText: energy }) - .textContent(); - await page.getByTestId("static-plan-energy").selectOption(optionText); - } - await page.getByTestId("static-plan-active").click(); - await page.getByRole("button", { name: "Close" }).click(); - await expect(lp.getByTestId("charging-plan")).toContainText(soc || energy); -} - -async function verifyRepeatingPlanAvailable(page, lp, expected) { - await lp.getByTestId("charging-plan-button").click(); - if (expected) { - await expect(page.getByTestId("repeating-plan-add")).toBeVisible(); - } else { - await expect(page.getByTestId("repeating-plan-add")).not.toBeVisible(); - } - await page.getByRole("button", { name: "Close" }).click(); -} - -test.describe("basic functionality", async () => { - test("vehicle with soc and capacity, set and restart", async ({ page }) => { - await page.goto("/"); - - const lp1 = await page.getByTestId("loadpoint").first(); - - // change vehicle - await lp1 - .getByTestId("change-vehicle") - .locator("select") - .selectOption("Vehicle with SoC with Capacity"); - - await expect(lp1.getByTestId("plan-marker")).not.toBeVisible(); - await expect(lp1.getByText("Loadpoint", { exact: true })).toBeVisible(); - - await lp1.getByTestId("limit-soc").getByRole("combobox").selectOption("90%"); - await lp1.getByRole("button", { name: "Solar", exact: true }).click(); - await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); - - await page.getByTestId("static-plan-day").selectOption({ index: 1 }); - await page.getByTestId("static-plan-time").fill("09:30"); - await page.getByTestId("static-plan-soc").selectOption("80%"); - await page.getByTestId("static-plan-precondition-lg-toggle").click(); - await page - .getByTestId("static-plan-precondition-lg-select") - .getByRole("combobox") - .selectOption("1 hour"); - await page.getByTestId("static-plan-active").click(); - await page.getByRole("button", { name: "Close" }).click(); - - await expect(lp1.getByTestId("plan-marker")).toBeVisible(); - await expect(lp1.getByTestId("charging-plan").getByRole("button")).toHaveText( - "tomorrow 09:3080%" - ); - - await expect(lp1.getByTestId("vehicle-status-charger")).toHaveText("Connected."); - await expect(lp1.getByTestId("vehicle-status-planstart")).toHaveText(/tomorrow .*/); - await expect(lp1.getByTestId("plan-marker")).toBeVisible(); - await expect(lp1.getByTestId("charging-plan").getByRole("button")).toHaveText( - "tomorrow 09:3080%" - ); - await lp1.getByTestId("charging-plan").getByRole("button").click(); - await expect(page.getByTestId("static-plan-soc")).toHaveValue("80"); - }); -}); - -test.describe("vehicle variations", async () => { - test.describe("guest vehicle", async () => { - test("kWh based plan and limit, no repeats", async ({ page }) => { - await page.goto("/"); - - const lp1 = await page.getByTestId("loadpoint").first(); - - await expect(lp1.getByTestId("vehicle-name")).toHaveText("Guest vehicle"); - - // kWh based limit - await lp1.getByTestId("limit-energy").getByRole("combobox").selectOption("50 kWh"); - - // kWh based plan - await setAndVerifyPlan(page, lp1, { energy: "25 kWh" }); - - // no repeating plans option - await verifyRepeatingPlanAvailable(page, lp1, false); - }); - }); - - test.describe("vehicle no soc no capacity", async () => { - test("kWh based plan and limit, no repeats", async ({ page }) => { - await page.goto("/"); - - const lp1 = await page.getByTestId("loadpoint").first(); - - // change vehicle - await lp1 - .getByTestId("change-vehicle") - .locator("select") - .selectOption("Vehicle no SoC no Capacity"); - - // kWh based limit - await lp1.getByTestId("limit-energy").getByRole("combobox").selectOption("50 kWh"); - - // kWh based plan - await setAndVerifyPlan(page, lp1, { energy: "25 kWh" }); - - // no repeating plans option - await verifyRepeatingPlanAvailable(page, lp1, false); - }); - }); - - test.describe("vehicle no soc with capacity", async () => { - test("kWh based plan and limit, no repeats", async ({ page }) => { - await page.goto("/"); - - const lp1 = await page.getByTestId("loadpoint").first(); - - // change vehicle - await lp1 - .getByTestId("change-vehicle") - .locator("select") - .selectOption("Vehicle no SoC with Capacity"); - - // kWh based limit - await lp1.getByTestId("limit-energy").getByRole("combobox").selectOption("50 kWh (+50%)"); - - // kWh based plan - await setAndVerifyPlan(page, lp1, { energy: "25 kWh" }); - - // no repeating plans option - await verifyRepeatingPlanAvailable(page, lp1, false); - }); - }); - - test.describe("vehicle with soc no capacity", async () => { - test("kWh based plan and soc based limit, no repeats", async ({ page }) => { - await page.goto("/"); - - const lp1 = await page.getByTestId("loadpoint").first(); - - // change vehicle - await lp1 - .getByTestId("change-vehicle") - .locator("select") - .selectOption("Vehicle with SoC no Capacity"); - - // soc based limit - await lp1.getByTestId("limit-soc").getByRole("combobox").selectOption("80%"); - - // soc based plan - await setAndVerifyPlan(page, lp1, { energy: "50 kWh" }); - - // no repeating plans option - await verifyRepeatingPlanAvailable(page, lp1, false); - }); - }); - - test.describe("vehicle with soc with capacity", async () => { - test("soc based plan and limit, with repeats", async ({ page }) => { - await page.goto("/"); - - const lp1 = await page.getByTestId("loadpoint").first(); - - // change vehicle - await lp1 - .getByTestId("change-vehicle") - .locator("select") - .selectOption("Vehicle with SoC with Capacity"); - - // soc based limit - await lp1.getByTestId("limit-soc").getByRole("combobox").selectOption("80%"); - - // soc based plan - await setAndVerifyPlan(page, lp1, { soc: "60%" }); - - // repeating plans option - await verifyRepeatingPlanAvailable(page, lp1, true); - }); - }); - - test.describe("loadpoint with soc, guest vehicle", async () => { - test("kWh based plan and soc based limit, no repeats", async ({ page }) => { - await page.goto("/"); - - const lp2 = await page.getByTestId("loadpoint").last(); - - // change vehicle - await expect(lp2.getByTestId("vehicle-name")).toHaveText("Guest vehicle"); - - // soc based limit - await lp2.getByTestId("limit-soc").getByRole("combobox").selectOption("80%"); - - // soc based plan - await setAndVerifyPlan(page, lp2, { energy: "50 kWh" }); - - // no repeating plans option - await verifyRepeatingPlanAvailable(page, lp2, false); - }); - }); - - test.describe("loadpoint with soc, vehicle with capacity", async () => { - test("soc based plan and limit, with repeats", async ({ page }) => { - await page.goto("/"); - - const lp2 = await page.getByTestId("loadpoint").last(); - - // change vehicle - await lp2 - .getByTestId("change-vehicle") - .locator("select") - .selectOption("Vehicle no SoC with Capacity"); - - // soc based limit - await lp2.getByTestId("limit-soc").getByRole("combobox").selectOption("80%"); - - // soc based plan - await setAndVerifyPlan(page, lp2, { soc: "60%" }); - - // repeating plans option - await verifyRepeatingPlanAvailable(page, lp2, true); - }); - }); -}); - -test.describe("preview", async () => { - const cases = [ - { - szenario: "kWh based plan", - vehicle: "Vehicle no SoC with Capacity", - goalId: "static-plan-energy", - }, - { - szenario: "soc based plan", - vehicle: "Vehicle with SoC with Capacity", - goalId: "static-plan-soc", - }, - ]; - - cases.forEach((c) => { - test(c.szenario, async ({ page }) => { - await page.goto("/"); - - const lp1 = await page.getByTestId("loadpoint").first(); - - // change vehicle - await lp1.getByTestId("change-vehicle").locator("select").selectOption(c.vehicle); - - await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); - - // initial set -> preview plan - await page.getByTestId("static-plan-day").selectOption({ index: 1 }); - await page.getByTestId("static-plan-time").fill("09:30"); - await page.getByTestId(c.goalId).selectOption("80"); - await expect(page.getByTestId("plan-preview-title")).toHaveText("Preview plan"); - - // activate -> active plan - await page.getByTestId("static-plan-active").click(); - await expect(page.getByTestId("plan-preview-title")).toHaveText("Active plan"); - - await page.getByTestId(c.goalId).selectOption("90"); - await expect(page.getByTestId("plan-preview-title")).toHaveText("Active plan"); - - // apply -> active plan - await expect(page.getByTestId("static-plan-apply")).toBeVisible(); - await page.getByTestId("static-plan-apply").click(); - await expect(page.getByTestId("plan-preview-title")).toHaveText("Active plan"); - - await page.getByTestId("static-plan-time").fill("23:30"); - await expect(page.getByTestId("plan-preview-title")).toHaveText("Active plan"); - await expect(page.getByTestId("static-plan-apply")).toBeVisible(); - - // deactivate - await page.getByTestId("static-plan-active").click(); - await expect(page.getByTestId("plan-preview-title")).toHaveText("Preview plan"); - }); - }); -}); - -test.describe("warnings", async () => { - test("goal not reachable in time", async ({ page }) => { - await page.goto("/"); - - const lp1 = await page.getByTestId("loadpoint").first(); - - // change vehicle - await lp1 - .getByTestId("change-vehicle") - .locator("select") - .selectOption("Vehicle with SoC with Massive Capacity"); - - await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); - - await page.getByTestId("static-plan-active").click(); - - // match this text but with fuzzy date "getByText('Goal will be reached 52:10 h')" - await expect(page.getByTestId("plan-warnings")).toHaveText(/Goal will be reached .* later/); - }); - test("time in the past", async ({ page }) => { - await page.goto("/"); - - const lp1 = await page.getByTestId("loadpoint").first(); - - // change vehicle - await lp1 - .getByTestId("change-vehicle") - .locator("select") - .selectOption("Vehicle with SoC with Capacity"); - - await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); - - await expect(page.getByTestId("plan-entry-warnings")).not.toBeVisible(); - - await page.getByTestId("static-plan-day").selectOption({ index: 0 }); - await page.getByTestId("static-plan-time").fill("00:01"); - - await expect(page.getByTestId("plan-entry-warnings")).toContainText( - "Pick a time in the future, Marty." - ); - await page.getByTestId("static-plan-time").fill("00:01"); - }); -}); - -test.describe("repeating", async () => { - test("add and remove plans", async ({ page }) => { - await page.goto("/"); - - const lp1 = await page.getByTestId("loadpoint").first(); - await lp1 - .getByTestId("change-vehicle") - .locator("select") - .selectOption("Vehicle with SoC with Capacity"); - - await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); - const modal = await page.getByTestId("charging-plan-modal"); - - // one static plan, no number - await expect(modal.getByTestId("plan-entry")).toHaveCount(1); - await expect(modal.getByTestId("plan-entry").first()).not.toContainText("#1"); - - // add plan - await modal.getByRole("button", { name: "Add repeating plan" }).click(); - await expect(modal.getByTestId("plan-entry")).toHaveCount(2); - await expect(modal.getByTestId("plan-entry").first()).toContainText("#1"); - await expect(modal.getByTestId("plan-entry").last()).toContainText("#2"); - - // remove plan - await modal.getByTestId("plan-entry").last().getByRole("button", { name: "Remove" }).click(); - await expect(modal.getByTestId("plan-entry")).toHaveCount(1); - await expect(modal.getByTestId("plan-entry").first()).not.toContainText("#1"); - }); - - test("preview static and repeating plan", async ({ page }) => { - await page.goto("/"); - - const lp1 = await page.getByTestId("loadpoint").first(); - await lp1 - .getByTestId("change-vehicle") - .locator("select") - .selectOption("Vehicle with SoC with Capacity"); - - await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); - const modal = await page.getByTestId("charging-plan-modal"); - - // one static plan - await expect(modal.getByTestId("plan-entry")).toHaveCount(1); - - await modal.getByTestId("static-plan-day").selectOption({ index: 1 }); // tomorrow - await modal.getByTestId("static-plan-time").fill("09:00"); - - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Preview plan"); - await expect(modal.getByTestId("target-text")).toContainText("09:00"); - - // add repeating plan - await modal.getByRole("button", { name: "Add repeating plan" }).click(); - await modal.getByTestId("repeating-plan-weekdays").click(); - await modal.getByRole("checkbox", { name: "Select all" }).check(); - await modal.getByTestId("repeating-plan-time").fill("11:11"); - - // switch between previews - await modal - .getByTestId("plan-preview-title") - .getByRole("combobox") - .selectOption("Preview plan #2"); - await expect(modal.getByTestId("target-text")).toContainText("11:11"); - - await modal - .getByTestId("plan-preview-title") - .getByRole("combobox") - .selectOption("Preview plan #1"); - await expect(modal.getByTestId("target-text")).toContainText("09:00"); - - // activate #1 - await modal.getByTestId("static-plan-active").click(); - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #1"); - await expect(modal.getByTestId("target-text")).toContainText("09:00"); - - // activate #2 - await modal.getByTestId("static-plan-active").click(); - await modal.getByTestId("repeating-plan-active").click(); - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); - await expect(modal.getByTestId("target-text")).toContainText("11:11"); - - // back to preview if no active plan - await modal.getByTestId("repeating-plan-active").click(); - await expect(modal.getByTestId("plan-preview-title").locator("option:checked")).toHaveText( - "Preview plan #1" - ); - await expect(modal.getByTestId("target-text")).toContainText("9:00"); - }); - - test("weekday selection", async ({ page }) => { - await page.goto("/"); - - const lp1 = await page.getByTestId("loadpoint").first(); - await lp1 - .getByTestId("change-vehicle") - .locator("select") - .selectOption("Vehicle with SoC with Capacity"); - - await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); - const modal = await page.getByTestId("charging-plan-modal"); - - await modal.getByRole("button", { name: "Add repeating plan" }).click(); - - // weekday select should have value "Mo-Fr" - await expect(modal.getByTestId("repeating-plan-weekdays").getByRole("button")).toHaveText( - "Mon – Fri" - ); - - // select all weekdays - await modal.getByTestId("repeating-plan-weekdays").click(); - await modal.getByRole("checkbox", { name: "Select all" }).click(); - await expect(modal.getByTestId("repeating-plan-weekdays").getByRole("button")).toHaveText( - "Mon – Sun" - ); - - // select none - await modal.getByRole("checkbox", { name: "Select all" }).click(); - await expect(modal.getByTestId("repeating-plan-weekdays").getByRole("button")).toHaveText("–"); - - // select specific weekdays - await modal.getByRole("checkbox", { name: "Thursday" }).check(); - await expect(modal.getByTestId("repeating-plan-weekdays").getByRole("button")).toHaveText( - "Thu" - ); - await modal.getByTestId("repeating-plan-weekdays").click(); // close - - // activate - await modal.getByTestId("repeating-plan-time").fill("02:22"); - await modal.getByTestId("repeating-plan-active").click(); - - // specific weekday and time - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); - await expect(modal.getByTestId("target-text")).toContainText("Thu 02:22"); - }); - - test("next plan", async ({ page }) => { - await page.goto("/"); - - const yesterday = getWeekday(-1); - const tomorrow = getWeekday(1); - - const lp1 = await page.getByTestId("loadpoint").first(); - await lp1 - .getByTestId("change-vehicle") - .locator("select") - .selectOption("Vehicle with SoC with Capacity"); - - await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); - const modal = await page.getByTestId("charging-plan-modal"); - - // configure static plan for tomorrow - const plan1 = modal.getByTestId("plan-entry").nth(0); - await plan1.getByTestId("static-plan-day").selectOption({ index: 1 }); - await plan1.getByTestId("static-plan-time").fill("09:30"); - await plan1.getByTestId("static-plan-soc").selectOption("80%"); - await plan1.getByTestId("static-plan-active").click(); - - // add repeating plan for tomorrow - await modal.getByRole("button", { name: "Add repeating plan" }).click(); - const plan2 = modal.getByTestId("plan-entry").nth(1); - const days2 = plan2.getByTestId("repeating-plan-weekdays"); - await days2.click(); - await days2.getByRole("checkbox", { name: "Select all" }).click(); - await days2.getByRole("checkbox", { name: "Select all" }).click(); - await days2.getByRole("checkbox", { name: tomorrow }).check(); - await days2.click(); // close - await plan2.getByTestId("repeating-plan-time").fill("09:20"); - await plan2.getByTestId("repeating-plan-active").click(); - - // add repeating plan for every day - await modal.getByRole("button", { name: "Add repeating plan" }).click(); - const plan3 = modal.getByTestId("plan-entry").last(); - const days3 = plan3.getByTestId("repeating-plan-weekdays"); - await days3.click(); - await days3.getByRole("checkbox", { name: "Select all" }).check(); - await days3.click(); // close - await plan3.getByTestId("repeating-plan-time").fill("09:10"); - await plan3.getByTestId("repeating-plan-active").click(); - - // check next plans - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #3"); - await expect(modal.getByTestId("target-text")).toContainText("9:10"); - - // disable plan #3 - await plan3.getByTestId("repeating-plan-active").click(); - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); - await expect(modal.getByTestId("target-text")).toContainText("9:20"); - - // change plan #2 to yesterday - await days2.click(); - await days2.getByRole("checkbox", { name: tomorrow }).click(); // uncheck - await days2.getByRole("checkbox", { name: yesterday }).click(); // check - await days2.click(); // close - // no changes yet - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); - await expect(modal.getByTestId("target-text")).toContainText("9:20"); - - // apply - await plan2.getByTestId("repeating-plan-apply").click(); - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #1"); - await expect(modal.getByTestId("target-text")).toContainText("9:30"); - - // set lower targets than vehicle soc (50%) - await plan1.getByTestId("static-plan-soc").selectOption("40%"); - await plan1.getByTestId("static-plan-apply").click(); - await plan2.getByTestId("repeating-plan-soc").selectOption("40%"); - await plan2.getByTestId("repeating-plan-apply").click(); - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Goal already reached"); - }); - - test("repeating plan persistence", async ({ page }) => { - await page.goto("/"); - - const tomorrow = getWeekday(1); - - let lp1 = await page.getByTestId("loadpoint").first(); - await lp1 - .getByTestId("change-vehicle") - .locator("select") - .selectOption("Vehicle with SoC with Capacity"); - - await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); - let modal = await page.getByTestId("charging-plan-modal"); - - await modal.getByRole("button", { name: "Add repeating plan" }).click(); - const plan = modal.getByTestId("plan-entry").nth(1); - await plan.getByTestId("repeating-plan-weekdays").click(); - await plan.getByRole("checkbox", { name: "Select all" }).click(); // check all - await plan.getByRole("checkbox", { name: "Select all" }).click(); // uncheck all - await plan.getByRole("checkbox", { name: tomorrow }).check(); - await plan.getByTestId("repeating-plan-time").fill("09:20"); - await plan.getByTestId("repeating-plan-precondition-lg-toggle").click(); - await plan - .getByTestId("repeating-plan-precondition-lg-select") - .getByRole("combobox") - .selectOption("2 hours"); - await plan.getByTestId("repeating-plan-active").click(); - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); - await expect(modal.getByTestId("target-text")).toContainText("09:20"); - - await restart(CONFIG); - await page.goto("/"); - - lp1 = await page.getByTestId("loadpoint").first(); - await lp1 - .getByTestId("change-vehicle") - .locator("select") - .selectOption("Vehicle with SoC with Capacity"); - - await lp1.getByTestId("charging-plan").getByRole("button", { name: "tomorrow 09:20" }).click(); - modal = await page.getByTestId("charging-plan-modal"); - await expect(modal.getByTestId("plan-entry")).toHaveCount(2); - await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); - await expect(modal.getByTestId("target-text")).toContainText("09:20"); - await expect(modal.getByTestId("repeating-plan-precondition-lg-toggle")).toBeChecked(); - await expect( - modal.getByTestId("repeating-plan-precondition-lg-select").locator("option:checked") - ).toHaveText("2 hours"); - }); -}); - -// add test for precondition, start with basic.evcc.yaml and verify that precondition toggle element is not visible. make dedicated describe block -test.describe("precondition", async () => { - test("only if dynamic tariff exists", async ({ page }) => { - await restart(CONFIG_NO_TARIFF); - await page.goto("/"); - const lp1 = await page.getByTestId("loadpoint").first(); - await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); - await expect(page.getByTestId("static-plan-active")).toBeVisible(); - await expect(page.getByTestId("static-plan-precondition-lg-toggle")).not.toBeVisible(); - await expect(page.getByTestId("static-plan-precondition-lg-select")).not.toBeVisible(); - - // verify small viewport - await page.setViewportSize(mobile); - await expect(page.getByTestId("static-plan-precondition-select")).not.toBeVisible(); - }); -}); diff --git a/tests/plan.spec.ts b/tests/plan.spec.ts new file mode 100644 index 000000000..d8001021b --- /dev/null +++ b/tests/plan.spec.ts @@ -0,0 +1,646 @@ +import { test, expect, devices, type Page, type Locator } from "@playwright/test"; +import { start, stop, baseUrl, restart } from "./evcc"; + +test.use({ baseURL: baseUrl() }); +test.describe.configure({ mode: "parallel" }); + +const mobile = devices["iPhone 12 Mini"].viewport; + +const CONFIG = "plan.evcc.yaml"; +const CONFIG_NO_TARIFF = "basics.evcc.yaml"; + +test.beforeEach(async () => { + await start(CONFIG); +}); + +test.afterEach(async () => { + await stop(); +}); + +function getWeekday(offset = 1) { + const date = new Date(); + date.setDate(date.getDate() + offset); + return date.toLocaleDateString("en-US", { weekday: "long" }); +} + +async function setAndVerifyPlan( + page: Page, + lp: Locator, + { soc, energy }: { soc?: string; energy?: string } +) { + await lp.getByTestId("charging-plan-button").click(); + + if (soc) { + await page.getByTestId("static-plan-soc").selectOption(soc); + } + if (energy) { + // select "25 kWh (+50%)" by providing "25 kWh" as option text + const optionText = await page + .getByTestId("static-plan-energy") + .locator("option", { hasText: energy }) + .textContent(); + await page.getByTestId("static-plan-energy").selectOption(optionText); + } + await page.getByTestId("static-plan-active").click(); + await page.getByRole("button", { name: "Close" }).click(); + await expect(lp.getByTestId("charging-plan")).toContainText(soc || energy!); +} + +async function verifyRepeatingPlanAvailable(page: Page, lp: Locator, expected: boolean) { + await lp.getByTestId("charging-plan-button").click(); + if (expected) { + await expect(page.getByTestId("repeating-plan-add")).toBeVisible(); + } else { + await expect(page.getByTestId("repeating-plan-add")).not.toBeVisible(); + } + await page.getByRole("button", { name: "Close" }).click(); +} + +test.describe("basic functionality", async () => { + test("vehicle with soc and capacity, set and restart", async ({ page }) => { + await page.goto("/"); + + const lp1 = await page.getByTestId("loadpoint").first(); + + // change vehicle + await lp1 + .getByTestId("change-vehicle") + .locator("select") + .selectOption("Vehicle with SoC with Capacity"); + + await expect(lp1.getByTestId("plan-marker")).not.toBeVisible(); + await expect(lp1.getByText("Loadpoint", { exact: true })).toBeVisible(); + + await lp1.getByTestId("limit-soc").getByRole("combobox").selectOption("90%"); + await lp1.getByRole("button", { name: "Solar", exact: true }).click(); + await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); + + await page.getByTestId("static-plan-day").selectOption({ index: 1 }); + await page.getByTestId("static-plan-time").fill("09:30"); + await page.getByTestId("static-plan-soc").selectOption("80%"); + await page.getByTestId("static-plan-precondition-lg-toggle").click(); + await page + .getByTestId("static-plan-precondition-lg-select") + .getByRole("combobox") + .selectOption("1 hour"); + await page.getByTestId("static-plan-active").click(); + await page.getByRole("button", { name: "Close" }).click(); + + await expect(lp1.getByTestId("plan-marker")).toBeVisible(); + await expect(lp1.getByTestId("charging-plan").getByRole("button")).toHaveText( + "tomorrow 09:3080%" + ); + + await expect(lp1.getByTestId("vehicle-status-charger")).toHaveText("Connected."); + await expect(lp1.getByTestId("vehicle-status-planstart")).toHaveText(/tomorrow .*/); + await expect(lp1.getByTestId("plan-marker")).toBeVisible(); + await expect(lp1.getByTestId("charging-plan").getByRole("button")).toHaveText( + "tomorrow 09:3080%" + ); + await lp1.getByTestId("charging-plan").getByRole("button").click(); + await expect(page.getByTestId("static-plan-soc")).toHaveValue("80"); + }); +}); + +test.describe("vehicle variations", async () => { + test.describe("guest vehicle", async () => { + test("kWh based plan and limit, no repeats", async ({ page }) => { + await page.goto("/"); + + const lp1 = await page.getByTestId("loadpoint").first(); + + await expect(lp1.getByTestId("vehicle-name")).toHaveText("Guest vehicle"); + + // kWh based limit + await lp1.getByTestId("limit-energy").getByRole("combobox").selectOption("50 kWh"); + + // kWh based plan + await setAndVerifyPlan(page, lp1, { energy: "25 kWh" }); + + // no repeating plans option + await verifyRepeatingPlanAvailable(page, lp1, false); + }); + }); + + test.describe("vehicle no soc no capacity", async () => { + test("kWh based plan and limit, no repeats", async ({ page }) => { + await page.goto("/"); + + const lp1 = await page.getByTestId("loadpoint").first(); + + // change vehicle + await lp1 + .getByTestId("change-vehicle") + .locator("select") + .selectOption("Vehicle no SoC no Capacity"); + + // kWh based limit + await lp1.getByTestId("limit-energy").getByRole("combobox").selectOption("50 kWh"); + + // kWh based plan + await setAndVerifyPlan(page, lp1, { energy: "25 kWh" }); + + // no repeating plans option + await verifyRepeatingPlanAvailable(page, lp1, false); + }); + }); + + test.describe("vehicle no soc with capacity", async () => { + test("kWh based plan and limit, no repeats", async ({ page }) => { + await page.goto("/"); + + const lp1 = await page.getByTestId("loadpoint").first(); + + // change vehicle + await lp1 + .getByTestId("change-vehicle") + .locator("select") + .selectOption("Vehicle no SoC with Capacity"); + + // kWh based limit + await lp1 + .getByTestId("limit-energy") + .getByRole("combobox") + .selectOption("50 kWh (+50%)"); + + // kWh based plan + await setAndVerifyPlan(page, lp1, { energy: "25 kWh" }); + + // no repeating plans option + await verifyRepeatingPlanAvailable(page, lp1, false); + }); + }); + + test.describe("vehicle with soc no capacity", async () => { + test("kWh based plan and soc based limit, no repeats", async ({ page }) => { + await page.goto("/"); + + const lp1 = await page.getByTestId("loadpoint").first(); + + // change vehicle + await lp1 + .getByTestId("change-vehicle") + .locator("select") + .selectOption("Vehicle with SoC no Capacity"); + + // soc based limit + await lp1.getByTestId("limit-soc").getByRole("combobox").selectOption("80%"); + + // soc based plan + await setAndVerifyPlan(page, lp1, { energy: "50 kWh" }); + + // no repeating plans option + await verifyRepeatingPlanAvailable(page, lp1, false); + }); + }); + + test.describe("vehicle with soc with capacity", async () => { + test("soc based plan and limit, with repeats", async ({ page }) => { + await page.goto("/"); + + const lp1 = await page.getByTestId("loadpoint").first(); + + // change vehicle + await lp1 + .getByTestId("change-vehicle") + .locator("select") + .selectOption("Vehicle with SoC with Capacity"); + + // soc based limit + await lp1.getByTestId("limit-soc").getByRole("combobox").selectOption("80%"); + + // soc based plan + await setAndVerifyPlan(page, lp1, { soc: "60%" }); + + // repeating plans option + await verifyRepeatingPlanAvailable(page, lp1, true); + }); + }); + + test.describe("loadpoint with soc, guest vehicle", async () => { + test("kWh based plan and soc based limit, no repeats", async ({ page }) => { + await page.goto("/"); + + const lp2 = await page.getByTestId("loadpoint").last(); + + // change vehicle + await expect(lp2.getByTestId("vehicle-name")).toHaveText("Guest vehicle"); + + // soc based limit + await lp2.getByTestId("limit-soc").getByRole("combobox").selectOption("80%"); + + // soc based plan + await setAndVerifyPlan(page, lp2, { energy: "50 kWh" }); + + // no repeating plans option + await verifyRepeatingPlanAvailable(page, lp2, false); + }); + }); + + test.describe("loadpoint with soc, vehicle with capacity", async () => { + test("soc based plan and limit, with repeats", async ({ page }) => { + await page.goto("/"); + + const lp2 = await page.getByTestId("loadpoint").last(); + + // change vehicle + await lp2 + .getByTestId("change-vehicle") + .locator("select") + .selectOption("Vehicle no SoC with Capacity"); + + // soc based limit + await lp2.getByTestId("limit-soc").getByRole("combobox").selectOption("80%"); + + // soc based plan + await setAndVerifyPlan(page, lp2, { soc: "60%" }); + + // repeating plans option + await verifyRepeatingPlanAvailable(page, lp2, true); + }); + }); +}); + +test.describe("preview", async () => { + const cases = [ + { + szenario: "kWh based plan", + vehicle: "Vehicle no SoC with Capacity", + goalId: "static-plan-energy", + }, + { + szenario: "soc based plan", + vehicle: "Vehicle with SoC with Capacity", + goalId: "static-plan-soc", + }, + ]; + + cases.forEach((c) => { + test(c.szenario, async ({ page }) => { + await page.goto("/"); + + const lp1 = await page.getByTestId("loadpoint").first(); + + // change vehicle + await lp1.getByTestId("change-vehicle").locator("select").selectOption(c.vehicle); + + await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); + + // initial set -> preview plan + await page.getByTestId("static-plan-day").selectOption({ index: 1 }); + await page.getByTestId("static-plan-time").fill("09:30"); + await page.getByTestId(c.goalId).selectOption("80"); + await expect(page.getByTestId("plan-preview-title")).toHaveText("Preview plan"); + + // activate -> active plan + await page.getByTestId("static-plan-active").click(); + await expect(page.getByTestId("plan-preview-title")).toHaveText("Active plan"); + + await page.getByTestId(c.goalId).selectOption("90"); + await expect(page.getByTestId("plan-preview-title")).toHaveText("Active plan"); + + // apply -> active plan + await expect(page.getByTestId("static-plan-apply")).toBeVisible(); + await page.getByTestId("static-plan-apply").click(); + await expect(page.getByTestId("plan-preview-title")).toHaveText("Active plan"); + + await page.getByTestId("static-plan-time").fill("23:30"); + await expect(page.getByTestId("plan-preview-title")).toHaveText("Active plan"); + await expect(page.getByTestId("static-plan-apply")).toBeVisible(); + + // deactivate + await page.getByTestId("static-plan-active").click(); + await expect(page.getByTestId("plan-preview-title")).toHaveText("Preview plan"); + }); + }); +}); + +test.describe("warnings", async () => { + test("goal not reachable in time", async ({ page }) => { + await page.goto("/"); + + const lp1 = await page.getByTestId("loadpoint").first(); + + // change vehicle + await lp1 + .getByTestId("change-vehicle") + .locator("select") + .selectOption("Vehicle with SoC with Massive Capacity"); + + await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); + + await page.getByTestId("static-plan-active").click(); + + // match this text but with fuzzy date "getByText('Goal will be reached 52:10 h')" + await expect(page.getByTestId("plan-warnings")).toHaveText(/Goal will be reached .* later/); + }); + test("time in the past", async ({ page }) => { + await page.goto("/"); + + const lp1 = await page.getByTestId("loadpoint").first(); + + // change vehicle + await lp1 + .getByTestId("change-vehicle") + .locator("select") + .selectOption("Vehicle with SoC with Capacity"); + + await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); + + await expect(page.getByTestId("plan-entry-warnings")).not.toBeVisible(); + + await page.getByTestId("static-plan-day").selectOption({ index: 0 }); + await page.getByTestId("static-plan-time").fill("00:01"); + + await expect(page.getByTestId("plan-entry-warnings")).toContainText( + "Pick a time in the future, Marty." + ); + await page.getByTestId("static-plan-time").fill("00:01"); + }); +}); + +test.describe("repeating", async () => { + test("add and remove plans", async ({ page }) => { + await page.goto("/"); + + const lp1 = await page.getByTestId("loadpoint").first(); + await lp1 + .getByTestId("change-vehicle") + .locator("select") + .selectOption("Vehicle with SoC with Capacity"); + + await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); + const modal = await page.getByTestId("charging-plan-modal"); + + // one static plan, no number + await expect(modal.getByTestId("plan-entry")).toHaveCount(1); + await expect(modal.getByTestId("plan-entry").first()).not.toContainText("#1"); + + // add plan + await modal.getByRole("button", { name: "Add repeating plan" }).click(); + await expect(modal.getByTestId("plan-entry")).toHaveCount(2); + await expect(modal.getByTestId("plan-entry").first()).toContainText("#1"); + await expect(modal.getByTestId("plan-entry").last()).toContainText("#2"); + + // remove plan + await modal + .getByTestId("plan-entry") + .last() + .getByRole("button", { name: "Remove" }) + .click(); + await expect(modal.getByTestId("plan-entry")).toHaveCount(1); + await expect(modal.getByTestId("plan-entry").first()).not.toContainText("#1"); + }); + + test("preview static and repeating plan", async ({ page }) => { + await page.goto("/"); + + const lp1 = await page.getByTestId("loadpoint").first(); + await lp1 + .getByTestId("change-vehicle") + .locator("select") + .selectOption("Vehicle with SoC with Capacity"); + + await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); + const modal = await page.getByTestId("charging-plan-modal"); + + // one static plan + await expect(modal.getByTestId("plan-entry")).toHaveCount(1); + + await modal.getByTestId("static-plan-day").selectOption({ index: 1 }); // tomorrow + await modal.getByTestId("static-plan-time").fill("09:00"); + + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Preview plan"); + await expect(modal.getByTestId("target-text")).toContainText("09:00"); + + // add repeating plan + await modal.getByRole("button", { name: "Add repeating plan" }).click(); + await modal.getByTestId("repeating-plan-weekdays").click(); + await modal.getByRole("checkbox", { name: "Select all" }).check(); + await modal.getByTestId("repeating-plan-time").fill("11:11"); + + // switch between previews + await modal + .getByTestId("plan-preview-title") + .getByRole("combobox") + .selectOption("Preview plan #2"); + await expect(modal.getByTestId("target-text")).toContainText("11:11"); + + await modal + .getByTestId("plan-preview-title") + .getByRole("combobox") + .selectOption("Preview plan #1"); + await expect(modal.getByTestId("target-text")).toContainText("09:00"); + + // activate #1 + await modal.getByTestId("static-plan-active").click(); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #1"); + await expect(modal.getByTestId("target-text")).toContainText("09:00"); + + // activate #2 + await modal.getByTestId("static-plan-active").click(); + await modal.getByTestId("repeating-plan-active").click(); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); + await expect(modal.getByTestId("target-text")).toContainText("11:11"); + + // back to preview if no active plan + await modal.getByTestId("repeating-plan-active").click(); + await expect(modal.getByTestId("plan-preview-title").locator("option:checked")).toHaveText( + "Preview plan #1" + ); + await expect(modal.getByTestId("target-text")).toContainText("9:00"); + }); + + test("weekday selection", async ({ page }) => { + await page.goto("/"); + + const lp1 = await page.getByTestId("loadpoint").first(); + await lp1 + .getByTestId("change-vehicle") + .locator("select") + .selectOption("Vehicle with SoC with Capacity"); + + await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); + const modal = await page.getByTestId("charging-plan-modal"); + + await modal.getByRole("button", { name: "Add repeating plan" }).click(); + + // weekday select should have value "Mo-Fr" + await expect(modal.getByTestId("repeating-plan-weekdays").getByRole("button")).toHaveText( + "Mon – Fri" + ); + + // select all weekdays + await modal.getByTestId("repeating-plan-weekdays").click(); + await modal.getByRole("checkbox", { name: "Select all" }).click(); + await expect(modal.getByTestId("repeating-plan-weekdays").getByRole("button")).toHaveText( + "Mon – Sun" + ); + + // select none + await modal.getByRole("checkbox", { name: "Select all" }).click(); + await expect(modal.getByTestId("repeating-plan-weekdays").getByRole("button")).toHaveText( + "–" + ); + + // select specific weekdays + await modal.getByRole("checkbox", { name: "Thursday" }).check(); + await expect(modal.getByTestId("repeating-plan-weekdays").getByRole("button")).toHaveText( + "Thu" + ); + await modal.getByTestId("repeating-plan-weekdays").click(); // close + + // activate + await modal.getByTestId("repeating-plan-time").fill("02:22"); + await modal.getByTestId("repeating-plan-active").click(); + + // specific weekday and time + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); + await expect(modal.getByTestId("target-text")).toContainText("Thu 02:22"); + }); + + test("next plan", async ({ page }) => { + await page.goto("/"); + + const yesterday = getWeekday(-1); + const tomorrow = getWeekday(1); + + const lp1 = await page.getByTestId("loadpoint").first(); + await lp1 + .getByTestId("change-vehicle") + .locator("select") + .selectOption("Vehicle with SoC with Capacity"); + + await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); + const modal = await page.getByTestId("charging-plan-modal"); + + // configure static plan for tomorrow + const plan1 = modal.getByTestId("plan-entry").nth(0); + await plan1.getByTestId("static-plan-day").selectOption({ index: 1 }); + await plan1.getByTestId("static-plan-time").fill("09:30"); + await plan1.getByTestId("static-plan-soc").selectOption("80%"); + await plan1.getByTestId("static-plan-active").click(); + + // add repeating plan for tomorrow + await modal.getByRole("button", { name: "Add repeating plan" }).click(); + const plan2 = modal.getByTestId("plan-entry").nth(1); + const days2 = plan2.getByTestId("repeating-plan-weekdays"); + await days2.click(); + await days2.getByRole("checkbox", { name: "Select all" }).click(); + await days2.getByRole("checkbox", { name: "Select all" }).click(); + await days2.getByRole("checkbox", { name: tomorrow }).check(); + await days2.click(); // close + await plan2.getByTestId("repeating-plan-time").fill("09:20"); + await plan2.getByTestId("repeating-plan-active").click(); + + // add repeating plan for every day + await modal.getByRole("button", { name: "Add repeating plan" }).click(); + const plan3 = modal.getByTestId("plan-entry").last(); + const days3 = plan3.getByTestId("repeating-plan-weekdays"); + await days3.click(); + await days3.getByRole("checkbox", { name: "Select all" }).check(); + await days3.click(); // close + await plan3.getByTestId("repeating-plan-time").fill("09:10"); + await plan3.getByTestId("repeating-plan-active").click(); + + // check next plans + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #3"); + await expect(modal.getByTestId("target-text")).toContainText("9:10"); + + // disable plan #3 + await plan3.getByTestId("repeating-plan-active").click(); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); + await expect(modal.getByTestId("target-text")).toContainText("9:20"); + + // change plan #2 to yesterday + await days2.click(); + await days2.getByRole("checkbox", { name: tomorrow }).click(); // uncheck + await days2.getByRole("checkbox", { name: yesterday }).click(); // check + await days2.click(); // close + // no changes yet + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); + await expect(modal.getByTestId("target-text")).toContainText("9:20"); + + // apply + await plan2.getByTestId("repeating-plan-apply").click(); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #1"); + await expect(modal.getByTestId("target-text")).toContainText("9:30"); + + // set lower targets than vehicle soc (50%) + await plan1.getByTestId("static-plan-soc").selectOption("40%"); + await plan1.getByTestId("static-plan-apply").click(); + await plan2.getByTestId("repeating-plan-soc").selectOption("40%"); + await plan2.getByTestId("repeating-plan-apply").click(); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Goal already reached"); + }); + + test("repeating plan persistence", async ({ page }) => { + await page.goto("/"); + + const tomorrow = getWeekday(1); + + let lp1 = await page.getByTestId("loadpoint").first(); + await lp1 + .getByTestId("change-vehicle") + .locator("select") + .selectOption("Vehicle with SoC with Capacity"); + + await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); + let modal = await page.getByTestId("charging-plan-modal"); + + await modal.getByRole("button", { name: "Add repeating plan" }).click(); + const plan = modal.getByTestId("plan-entry").nth(1); + await plan.getByTestId("repeating-plan-weekdays").click(); + await plan.getByRole("checkbox", { name: "Select all" }).click(); // check all + await plan.getByRole("checkbox", { name: "Select all" }).click(); // uncheck all + await plan.getByRole("checkbox", { name: tomorrow }).check(); + await plan.getByTestId("repeating-plan-time").fill("09:20"); + await plan.getByTestId("repeating-plan-precondition-lg-toggle").click(); + await plan + .getByTestId("repeating-plan-precondition-lg-select") + .getByRole("combobox") + .selectOption("2 hours"); + await plan.getByTestId("repeating-plan-active").click(); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); + await expect(modal.getByTestId("target-text")).toContainText("09:20"); + + await restart(CONFIG); + await page.goto("/"); + + lp1 = await page.getByTestId("loadpoint").first(); + await lp1 + .getByTestId("change-vehicle") + .locator("select") + .selectOption("Vehicle with SoC with Capacity"); + + await lp1 + .getByTestId("charging-plan") + .getByRole("button", { name: "tomorrow 09:20" }) + .click(); + modal = await page.getByTestId("charging-plan-modal"); + await expect(modal.getByTestId("plan-entry")).toHaveCount(2); + await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); + await expect(modal.getByTestId("target-text")).toContainText("09:20"); + await expect(modal.getByTestId("repeating-plan-precondition-lg-toggle")).toBeChecked(); + await expect( + modal.getByTestId("repeating-plan-precondition-lg-select").locator("option:checked") + ).toHaveText("2 hours"); + }); +}); + +// add test for precondition, start with basic.evcc.yaml and verify that precondition toggle element is not visible. make dedicated describe block +test.describe("precondition", async () => { + test("only if dynamic tariff exists", async ({ page }) => { + await restart(CONFIG_NO_TARIFF); + await page.goto("/"); + const lp1 = await page.getByTestId("loadpoint").first(); + await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); + await expect(page.getByTestId("static-plan-active")).toBeVisible(); + await expect(page.getByTestId("static-plan-precondition-lg-toggle")).not.toBeVisible(); + await expect(page.getByTestId("static-plan-precondition-lg-select")).not.toBeVisible(); + + // verify small viewport + await page.setViewportSize(mobile); + await expect(page.getByTestId("static-plan-precondition-select")).not.toBeVisible(); + }); +}); diff --git a/tests/sessions.spec.js b/tests/sessions.spec.js deleted file mode 100644 index e7cc2af71..000000000 --- a/tests/sessions.spec.js +++ /dev/null @@ -1,332 +0,0 @@ -import { test, expect, devices } from "@playwright/test"; -import { start, stop, baseUrl } from "./evcc"; - -test.use({ baseURL: baseUrl() }); - -const mobile = devices["iPhone 12 Mini"].viewport; -const desktop = devices["Desktop Chrome"].viewport; - -test.beforeAll(async () => { - await start("basics.evcc.yaml", "sessions.sql"); -}); -test.afterAll(async () => { - await stop(); -}); - -async function selectLoadpointFilter(page, value) { - await page - .getByTestId("filter-loadpoint") - .locator("visible=true") - .getByRole("combobox") - .selectOption(value); -} - -async function selectVehicleFilter(page, value) { - await page - .getByTestId("filter-vehicle") - .locator("visible=true") - .getByRole("combobox") - .selectOption(value); -} - -test.describe("basics", async () => { - test("navigation to sessions", async ({ page }) => { - await page.goto("/"); - const topNavigationButton = await page.getByTestId("topnavigation-button"); - await topNavigationButton.click(); - await page - .getByTestId("topnavigation-dropdown") - .getByRole("link", { name: "Charging sessions" }) - .click(); - await expect(page.getByRole("heading", { name: "Charging Sessions" })).toBeVisible(); - }); - test("month without data", async ({ page }) => { - await page.goto("/#/sessions?year=2023&month=6"); - await expect(page.getByTestId("sessions-nodata")).toHaveCount(1); - await expect(page.getByTestId("sessions-entry")).toHaveCount(0); - }); - test("month with data", async ({ page }) => { - await page.setViewportSize(desktop); - await page.goto("/#/sessions?year=2023&month=5"); - await expect(page.getByTestId("navigate-month")).toHaveText("May"); - await expect(page.getByTestId("navigate-year")).toHaveText("2023"); - await expect(page.getByTestId("sessions-nodata")).toHaveCount(0); - await expect(page.getByRole("table")).toBeVisible(); - await expect(page.getByTestId("sessions-head")).toHaveCount(1); - await expect(page.getByTestId("sessions-head").locator("th")).toHaveCount(9); - - await expect(page.getByTestId("sessions-head-energy")).toContainText("ChargedkWh"); - await expect(page.getByTestId("sessions-foot-energy")).toBeVisible("20.0"); - - await expect(page.getByTestId("sessions-head-solar")).toContainText("Solar%"); - await expect(page.getByTestId("sessions-foot-solar")).toBeVisible("67.3"); - - await expect(page.getByTestId("sessions-head-price")).toContainText("Cost€"); - await expect(page.getByTestId("sessions-foot-price")).toBeVisible("5.50"); - - await expect(page.getByTestId("sessions-head-avgPrice")).toContainText("⌀ Pricect/kWh"); - await expect(page.getByTestId("sessions-foot-avgPrice")).toBeVisible("27.5"); - - await expect(page.getByTestId("sessions-head-chargeDuration")).toContainText("Durationh:mm"); - await expect(page.getByTestId("sessions-foot-chargeDuration")).toBeVisible("1:30"); - - await page - .getByTestId("sessions-head-chargeDuration") - .getByRole("combobox") - .selectOption("⌀ Power"); - await expect(page.getByTestId("sessions-head-avgPower")).toContainText("⌀ PowerkW"); - await expect(page.getByTestId("sessions-foot-avgPower")).toBeVisible("1:30"); - - await expect(page.getByTestId("sessions-entry")).toHaveCount(4); - }); -}); - -test.describe("mobile basics", async () => { - test("column select", async ({ page }) => { - await page.setViewportSize(mobile); - await page.goto("/#/sessions?year=2023&month=5"); - - await expect(page.getByTestId("sessions-head").locator("th")).toHaveCount(5); - - await expect(page.getByTestId("sessions-head-energy")).toContainText("ChargedkWh"); - await expect(page.getByTestId("sessions-foot-energy")).toBeVisible("20.0"); - - await page.getByTestId("sessions-head-energy").getByRole("combobox").selectOption("Solar"); - await expect(page.getByTestId("sessions-head-solar")).toContainText("Solar%"); - await expect(page.getByTestId("sessions-foot-solar")).toBeVisible("67.3"); - - await page.getByTestId("sessions-head-solar").getByRole("combobox").selectOption("Cost"); - await expect(page.getByTestId("sessions-head-price")).toContainText("Cost€"); - await expect(page.getByTestId("sessions-foot-price")).toBeVisible("5.50"); - - await page.getByTestId("sessions-head-price").getByRole("combobox").selectOption("⌀ Price"); - await expect(page.getByTestId("sessions-head-avgPrice")).toContainText("⌀ Pricect/kWh"); - await expect(page.getByTestId("sessions-foot-avgPrice")).toBeVisible("27.5"); - }); - - test("keep selection when paging", async ({ page }) => { - await page.setViewportSize(mobile); - await page.goto("/#/sessions?year=2023&month=5"); - - await page.getByTestId("sessions-head-energy").getByRole("combobox").selectOption("Solar"); - await page.getByTestId("navigate-next-year-month").click(); - await page.getByTestId("navigate-prev-year-month").click(); - await expect(page.getByTestId("sessions-head-solar")).toContainText("Solar%"); - }); -}); - -test.describe("paging", async () => { - test("prev/next links", async ({ page }) => { - await page.goto("/#/sessions?year=2023&month=5"); - await expect(page.getByTestId("navigate-next-month")).not.toBeDisabled(); - await expect(page.getByTestId("navigate-prev-month")).not.toBeDisabled(); - }); - test("next month", async ({ page }) => { - await page.goto("/#/sessions?year=2023&month=5"); - await page.getByTestId("navigate-next-month").click(); - await expect(page.getByTestId("navigate-month")).toHaveText("June"); - await expect(page.getByTestId("navigate-year")).toHaveText("2023"); - await expect(page.getByTestId("navigate-next-month")).not.toBeDisabled(); - await expect(page.getByTestId("navigate-prev-month")).not.toBeDisabled(); - }); - test("prev month", async ({ page }) => { - await page.goto("/#/sessions?year=2023&month=6"); - await page.getByTestId("navigate-prev-month").click(); - await expect(page.getByTestId("navigate-month")).toHaveText("May"); - await expect(page.getByTestId("navigate-year")).toHaveText("2023"); - }); -}); - -for (const [name, viewport] of Object.entries({ desktop, mobile })) { - test.describe(`filter on ${name}`, async () => { - test("by vehicle", async ({ page }) => { - await page.setViewportSize(viewport); - await page.goto("/#/sessions?year=2023&month=5"); - await expect(page.getByTestId("sessions-entry")).toHaveCount(4); - - await selectVehicleFilter(page, "blauer e-Golf (2)"); - await expect(page.getByTestId("sessions-entry")).toHaveCount(2); - await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/blauer e-Golf/); - await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/blauer e-Golf/); - - await selectVehicleFilter(page, "weißes Model 3 (2)"); - await expect(page.getByTestId("sessions-entry")).toHaveCount(2); - await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/weißes Model 3/); - await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/weißes Model 3/); - - await selectVehicleFilter(page, "all vehicles (4)"); - await expect(page.getByTestId("sessions-entry")).toHaveCount(4); - }); - - test("by loadpoint", async ({ page }) => { - await page.setViewportSize(viewport); - await page.goto("/#/sessions?year=2023&month=5"); - await expect(page.getByTestId("sessions-entry")).toHaveCount(4); - - await selectLoadpointFilter(page, "Carport (3)"); - await expect(page.getByTestId("sessions-entry")).toHaveCount(3); - await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Carport/); - await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/Carport/); - await expect(page.getByTestId("sessions-entry").nth(2)).toHaveText(/Carport/); - - await selectLoadpointFilter(page, "Garage (1)"); - await expect(page.getByTestId("sessions-entry")).toHaveCount(1); - await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Garage/); - - await selectLoadpointFilter(page, "all charging points (4)"); - await expect(page.getByTestId("sessions-entry")).toHaveCount(4); - }); - - test("by vehicle and loadpoint", async ({ page }) => { - await page.setViewportSize(viewport); - await page.goto("/#/sessions?year=2023&month=5"); - - await selectLoadpointFilter(page, "Carport (3)"); - await selectVehicleFilter(page, "weißes Model 3 (1)"); - await expect(page.getByTestId("sessions-entry")).toHaveCount(1); - await expect(page.getByTestId("sessions-entry")).toHaveText(/Carport/); - await expect(page.getByTestId("sessions-entry")).toHaveText(/weißes Model 3/); - - await selectVehicleFilter(page, "blauer e-Golf (2)"); - await expect(page.getByTestId("sessions-entry")).toHaveCount(2); - await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Carport/); - await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/blauer e-Golf/); - await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/Carport/); - await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/blauer e-Golf/); - }); - - test("by vehicle and loadpoint disabled options", async ({ page }) => { - await page.setViewportSize(viewport); - await page.goto("/#/sessions?year=2023&month=5"); - - await selectVehicleFilter(page, "blauer e-Golf (2)"); - const option = page - .getByTestId("filter-loadpoint") - .locator("visible=true") - .locator("option[value=Garage]"); - await expect(option).toHaveAttribute("disabled", ""); - await expect(option).toHaveText("Garage (0)"); - }); - - test("keep filter when paging", async ({ page }) => { - await page.setViewportSize(viewport); - await page.goto("/#/sessions?year=2023&month=5"); - - await selectLoadpointFilter(page, "Carport (3)"); - await expect(page.getByTestId("sessions-entry")).toHaveCount(3); - if (name === "mobile") { - await page.getByTestId("navigate-next-year-month").click(); - await page.getByTestId("navigate-prev-year-month").click(); - } else { - await page.getByTestId("navigate-next-month").click(); - await page.getByTestId("navigate-prev-month").click(); - } - await expect(page.getByTestId("sessions-entry")).toHaveCount(3); - }); - }); -} - -test.describe("columns desktop", async () => { - test("show vehicle column if multiple different exist", async ({ page }) => { - await page.goto("/#/sessions?year=2023&month=5"); - await expect(page.getByTestId("vehicle")).toBeVisible(); - }); - test("show loadpoint column if multiple different exist", async ({ page }) => { - await page.goto("/#/sessions?year=2023&month=5"); - await expect(page.getByTestId("loadpoint")).toBeVisible(); - }); - test("show co2 column it has values", async ({ page }) => { - await page.goto("/#/sessions?year=2023&month=3"); - await expect(page.getByTestId("sessions-head-co2")).toBeVisible(); - }); - test("hide co2 column if it doesnt have values", async ({ page }) => { - await page.goto("/#/sessions?year=2023&month=5"); - await expect(page.getByTestId("sessions-head-co2")).toHaveCount(0); - }); -}); - -const date = new Date(); -const YEAR = date.getFullYear(); -const MONTH = date.getMonth() + 1; -const MONTH_YEAR = new Intl.DateTimeFormat("en", { month: "long", year: "numeric" }).format(date); - -test.describe("csv export", async () => { - test("total export", async ({ page }) => { - await page.goto("/#/sessions?period=total"); - await expect(page.getByRole("link", { name: "Download total CSV" })).toHaveAttribute( - "href", - "./api/sessions?format=csv&lang=en" - ); - }); - test("year export", async ({ page }) => { - // fixed year - await page.goto("/#/sessions?period=year&year=2023"); - await expect(page.getByRole("link", { name: "Download 2023 CSV" })).toHaveAttribute( - "href", - "./api/sessions?format=csv&lang=en&year=2023" - ); - - // current year - await page.goto(`/#/sessions?period=year`); - await expect(page.getByRole("link", { name: `Download ${YEAR} CSV` })).toHaveAttribute( - "href", - `./api/sessions?format=csv&lang=en&year=${YEAR}` - ); - }); - test("monthly export", async ({ page }) => { - await page.goto("/#/sessions?&year=2023&month=5"); - await expect(page.getByRole("link", { name: "Download May 2023 CSV" })).toHaveAttribute( - "href", - "./api/sessions?format=csv&lang=en&year=2023&month=5" - ); - - // current month - await page.goto(`/#/sessions`); - await expect(page.getByRole("link", { name: `Download ${MONTH_YEAR} CSV` })).toHaveAttribute( - "href", - `./api/sessions?format=csv&lang=en&year=${YEAR}&month=${MONTH}` - ); - }); -}); - -test.describe("session details", async () => { - test("show session details (session 5)", async ({ page }) => { - await page.goto("/#/sessions?year=2023&month=5"); - await page.getByTestId("sessions-entry").nth(0).click(); - await expect(page.getByTestId("session-details")).toBeVisible(); - - await expect( - page.getByTestId("session-details").getByRole("heading", { name: "Charging Session" }) - ).toBeVisible(); - await expect(page.getByTestId("session-details-loadpoint")).toContainText("Garage"); - await expect(page.getByTestId("session-details-vehicle")).toContainText("weißes Model 3"); - await expect(page.getByTestId("session-details-date")).toContainText( - ["Thu, May 4, 22:00", "Fri, May 5, 06:00"].join("") - ); - await expect(page.getByTestId("session-details-energy")).toContainText("5.0 kWh"); - await expect(page.getByTestId("session-details-energy")).toContainText("1:00"); - await expect(page.getByTestId("session-details-solar")).toContainText("0.0% (0.0 kWh)"); - await expect(page.getByTestId("session-details-price")).toContainText("2.50 € 50.0 ct/kWh"); - await expect(page.getByTestId("session-details-co2")).toHaveCount(0); - await expect(page.getByTestId("session-details-odometer")).toHaveCount(0); - await expect(page.getByTestId("session-details-meter")).toHaveCount(0); - await expect(page.getByTestId("session-details-delete")).toContainText("Delete"); - }); - - test("show session details with CO2 data (session 1)", async ({ page }) => { - await page.goto("/#/sessions?year=2023&month=3"); - await page.getByTestId("sessions-entry").nth(0).click(); - await expect(page.getByTestId("session-details")).toBeVisible(); - - await expect(page.getByTestId("session-details-loadpoint")).toContainText("Carport"); - await expect(page.getByTestId("session-details-vehicle")).toContainText("blauer e-Golf"); - await expect(page.getByTestId("session-details-date")).toContainText( - ["Wed, Mar 1, 07:00", "Tue, May 2, 12:00"].join("") - ); - await expect(page.getByTestId("session-details-energy")).toContainText("10.0 kWh"); - await expect(page.getByTestId("session-details-energy")).toContainText("1:00"); - await expect(page.getByTestId("session-details-solar")).toContainText("100.0% (10.0 kWh)"); - await expect(page.getByTestId("session-details-price")).toContainText("2.00 € 20.0 ct/kWh"); - await expect(page.getByTestId("session-details-co2")).toContainText("300 g/kWh"); - }); -}); diff --git a/tests/sessions.spec.ts b/tests/sessions.spec.ts new file mode 100644 index 000000000..15beb4323 --- /dev/null +++ b/tests/sessions.spec.ts @@ -0,0 +1,343 @@ +import { test, expect, devices, type Page } from "@playwright/test"; +import { start, stop, baseUrl } from "./evcc"; + +test.use({ baseURL: baseUrl() }); + +const mobile = devices["iPhone 12 Mini"].viewport; +const desktop = devices["Desktop Chrome"].viewport; + +test.beforeAll(async () => { + await start("basics.evcc.yaml", "sessions.sql"); +}); +test.afterAll(async () => { + await stop(); +}); + +async function selectLoadpointFilter(page: Page, value: string) { + await page + .getByTestId("filter-loadpoint") + .locator("visible=true") + .getByRole("combobox") + .selectOption(value); +} + +async function selectVehicleFilter(page: Page, value: string) { + await page + .getByTestId("filter-vehicle") + .locator("visible=true") + .getByRole("combobox") + .selectOption(value); +} + +test.describe("basics", async () => { + test("navigation to sessions", async ({ page }) => { + await page.goto("/"); + const topNavigationButton = await page.getByTestId("topnavigation-button"); + await topNavigationButton.click(); + await page + .getByTestId("topnavigation-dropdown") + .getByRole("link", { name: "Charging sessions" }) + .click(); + await expect(page.getByRole("heading", { name: "Charging Sessions" })).toBeVisible(); + }); + test("month without data", async ({ page }) => { + await page.goto("/#/sessions?year=2023&month=6"); + await expect(page.getByTestId("sessions-nodata")).toHaveCount(1); + await expect(page.getByTestId("sessions-entry")).toHaveCount(0); + }); + test("month with data", async ({ page }) => { + await page.setViewportSize(desktop); + await page.goto("/#/sessions?year=2023&month=5"); + await expect(page.getByTestId("navigate-month")).toHaveText("May"); + await expect(page.getByTestId("navigate-year")).toHaveText("2023"); + await expect(page.getByTestId("sessions-nodata")).toHaveCount(0); + await expect(page.getByRole("table")).toBeVisible(); + await expect(page.getByTestId("sessions-head")).toHaveCount(1); + await expect(page.getByTestId("sessions-head").locator("th")).toHaveCount(9); + + await expect(page.getByTestId("sessions-head-energy")).toContainText("ChargedkWh"); + await expect(page.getByTestId("sessions-foot-energy")).toBeVisible(); + await expect(page.getByTestId("sessions-foot-energy")).toHaveText("20.0"); + + await expect(page.getByTestId("sessions-head-solar")).toContainText("Solar%"); + await expect(page.getByTestId("sessions-foot-solar")).toBeVisible(); + await expect(page.getByTestId("sessions-foot-solar")).toHaveText("67.3"); + + await expect(page.getByTestId("sessions-head-price")).toContainText("Cost€"); + await expect(page.getByTestId("sessions-foot-price")).toBeVisible(); + await expect(page.getByTestId("sessions-foot-price")).toHaveText("5.50"); + + await expect(page.getByTestId("sessions-head-avgPrice")).toContainText("⌀ Pricect/kWh"); + await expect(page.getByTestId("sessions-foot-avgPrice")).toBeVisible(); + await expect(page.getByTestId("sessions-foot-avgPrice")).toHaveText("27.5"); + + await expect(page.getByTestId("sessions-head-chargeDuration")).toContainText( + "Durationh:mm" + ); + await expect(page.getByTestId("sessions-foot-chargeDuration")).toBeVisible(); + await expect(page.getByTestId("sessions-foot-chargeDuration")).toHaveText("1:30"); + + await page + .getByTestId("sessions-head-chargeDuration") + .getByRole("combobox") + .selectOption("⌀ Power"); + await expect(page.getByTestId("sessions-head-avgPower")).toContainText("⌀ PowerkW"); + await expect(page.getByTestId("sessions-foot-avgPower")).toBeVisible(); + await expect(page.getByTestId("sessions-foot-avgPower")).toHaveText("10.0"); + + await expect(page.getByTestId("sessions-entry")).toHaveCount(4); + }); +}); + +test.describe("mobile basics", async () => { + test("column select", async ({ page }) => { + await page.setViewportSize(mobile); + await page.goto("/#/sessions?year=2023&month=5"); + + await expect(page.getByTestId("sessions-head").locator("th")).toHaveCount(5); + + await expect(page.getByTestId("sessions-head-energy")).toContainText("ChargedkWh"); + await expect(page.getByTestId("sessions-foot-energy")).toBeVisible(); + await expect(page.getByTestId("sessions-foot-energy")).toHaveText("20.0"); + + await page.getByTestId("sessions-head-energy").getByRole("combobox").selectOption("Solar"); + await expect(page.getByTestId("sessions-head-solar")).toContainText("Solar%"); + await expect(page.getByTestId("sessions-foot-solar")).toBeVisible(); + await expect(page.getByTestId("sessions-foot-solar")).toHaveText("67.3"); + + await page.getByTestId("sessions-head-solar").getByRole("combobox").selectOption("Cost"); + await expect(page.getByTestId("sessions-head-price")).toContainText("Cost€"); + await expect(page.getByTestId("sessions-foot-price")).toBeVisible(); + await expect(page.getByTestId("sessions-foot-price")).toHaveText("5.50"); + + await page.getByTestId("sessions-head-price").getByRole("combobox").selectOption("⌀ Price"); + await expect(page.getByTestId("sessions-head-avgPrice")).toContainText("⌀ Pricect/kWh"); + await expect(page.getByTestId("sessions-foot-avgPrice")).toBeVisible(); + await expect(page.getByTestId("sessions-foot-avgPrice")).toHaveText("27.5"); + }); + + test("keep selection when paging", async ({ page }) => { + await page.setViewportSize(mobile); + await page.goto("/#/sessions?year=2023&month=5"); + + await page.getByTestId("sessions-head-energy").getByRole("combobox").selectOption("Solar"); + await page.getByTestId("navigate-next-year-month").click(); + await page.getByTestId("navigate-prev-year-month").click(); + await expect(page.getByTestId("sessions-head-solar")).toContainText("Solar%"); + }); +}); + +test.describe("paging", async () => { + test("prev/next links", async ({ page }) => { + await page.goto("/#/sessions?year=2023&month=5"); + await expect(page.getByTestId("navigate-next-month")).not.toBeDisabled(); + await expect(page.getByTestId("navigate-prev-month")).not.toBeDisabled(); + }); + test("next month", async ({ page }) => { + await page.goto("/#/sessions?year=2023&month=5"); + await page.getByTestId("navigate-next-month").click(); + await expect(page.getByTestId("navigate-month")).toHaveText("June"); + await expect(page.getByTestId("navigate-year")).toHaveText("2023"); + await expect(page.getByTestId("navigate-next-month")).not.toBeDisabled(); + await expect(page.getByTestId("navigate-prev-month")).not.toBeDisabled(); + }); + test("prev month", async ({ page }) => { + await page.goto("/#/sessions?year=2023&month=6"); + await page.getByTestId("navigate-prev-month").click(); + await expect(page.getByTestId("navigate-month")).toHaveText("May"); + await expect(page.getByTestId("navigate-year")).toHaveText("2023"); + }); +}); + +for (const [name, viewport] of Object.entries({ desktop, mobile })) { + test.describe(`filter on ${name}`, async () => { + test("by vehicle", async ({ page }) => { + await page.setViewportSize(viewport); + await page.goto("/#/sessions?year=2023&month=5"); + await expect(page.getByTestId("sessions-entry")).toHaveCount(4); + + await selectVehicleFilter(page, "blauer e-Golf (2)"); + await expect(page.getByTestId("sessions-entry")).toHaveCount(2); + await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/blauer e-Golf/); + await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/blauer e-Golf/); + + await selectVehicleFilter(page, "weißes Model 3 (2)"); + await expect(page.getByTestId("sessions-entry")).toHaveCount(2); + await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/weißes Model 3/); + await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/weißes Model 3/); + + await selectVehicleFilter(page, "all vehicles (4)"); + await expect(page.getByTestId("sessions-entry")).toHaveCount(4); + }); + + test("by loadpoint", async ({ page }) => { + await page.setViewportSize(viewport); + await page.goto("/#/sessions?year=2023&month=5"); + await expect(page.getByTestId("sessions-entry")).toHaveCount(4); + + await selectLoadpointFilter(page, "Carport (3)"); + await expect(page.getByTestId("sessions-entry")).toHaveCount(3); + await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Carport/); + await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/Carport/); + await expect(page.getByTestId("sessions-entry").nth(2)).toHaveText(/Carport/); + + await selectLoadpointFilter(page, "Garage (1)"); + await expect(page.getByTestId("sessions-entry")).toHaveCount(1); + await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Garage/); + + await selectLoadpointFilter(page, "all charging points (4)"); + await expect(page.getByTestId("sessions-entry")).toHaveCount(4); + }); + + test("by vehicle and loadpoint", async ({ page }) => { + await page.setViewportSize(viewport); + await page.goto("/#/sessions?year=2023&month=5"); + + await selectLoadpointFilter(page, "Carport (3)"); + await selectVehicleFilter(page, "weißes Model 3 (1)"); + await expect(page.getByTestId("sessions-entry")).toHaveCount(1); + await expect(page.getByTestId("sessions-entry")).toHaveText(/Carport/); + await expect(page.getByTestId("sessions-entry")).toHaveText(/weißes Model 3/); + + await selectVehicleFilter(page, "blauer e-Golf (2)"); + await expect(page.getByTestId("sessions-entry")).toHaveCount(2); + await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Carport/); + await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/blauer e-Golf/); + await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/Carport/); + await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/blauer e-Golf/); + }); + + test("by vehicle and loadpoint disabled options", async ({ page }) => { + await page.setViewportSize(viewport); + await page.goto("/#/sessions?year=2023&month=5"); + + await selectVehicleFilter(page, "blauer e-Golf (2)"); + const option = page + .getByTestId("filter-loadpoint") + .locator("visible=true") + .locator("option[value=Garage]"); + await expect(option).toHaveAttribute("disabled", ""); + await expect(option).toHaveText("Garage (0)"); + }); + + test("keep filter when paging", async ({ page }) => { + await page.setViewportSize(viewport); + await page.goto("/#/sessions?year=2023&month=5"); + + await selectLoadpointFilter(page, "Carport (3)"); + await expect(page.getByTestId("sessions-entry")).toHaveCount(3); + if (name === "mobile") { + await page.getByTestId("navigate-next-year-month").click(); + await page.getByTestId("navigate-prev-year-month").click(); + } else { + await page.getByTestId("navigate-next-month").click(); + await page.getByTestId("navigate-prev-month").click(); + } + await expect(page.getByTestId("sessions-entry")).toHaveCount(3); + }); + }); +} + +test.describe("columns desktop", async () => { + test("show vehicle column if multiple different exist", async ({ page }) => { + await page.goto("/#/sessions?year=2023&month=5"); + await expect(page.getByTestId("vehicle")).toBeVisible(); + }); + test("show loadpoint column if multiple different exist", async ({ page }) => { + await page.goto("/#/sessions?year=2023&month=5"); + await expect(page.getByTestId("loadpoint")).toBeVisible(); + }); + test("show co2 column it has values", async ({ page }) => { + await page.goto("/#/sessions?year=2023&month=3"); + await expect(page.getByTestId("sessions-head-co2")).toBeVisible(); + }); + test("hide co2 column if it doesnt have values", async ({ page }) => { + await page.goto("/#/sessions?year=2023&month=5"); + await expect(page.getByTestId("sessions-head-co2")).toHaveCount(0); + }); +}); + +const date = new Date(); +const YEAR = date.getFullYear(); +const MONTH = date.getMonth() + 1; +const MONTH_YEAR = new Intl.DateTimeFormat("en", { month: "long", year: "numeric" }).format(date); + +test.describe("csv export", async () => { + test("total export", async ({ page }) => { + await page.goto("/#/sessions?period=total"); + await expect(page.getByRole("link", { name: "Download total CSV" })).toHaveAttribute( + "href", + "./api/sessions?format=csv&lang=en" + ); + }); + test("year export", async ({ page }) => { + // fixed year + await page.goto("/#/sessions?period=year&year=2023"); + await expect(page.getByRole("link", { name: "Download 2023 CSV" })).toHaveAttribute( + "href", + "./api/sessions?format=csv&lang=en&year=2023" + ); + + // current year + await page.goto(`/#/sessions?period=year`); + await expect(page.getByRole("link", { name: `Download ${YEAR} CSV` })).toHaveAttribute( + "href", + `./api/sessions?format=csv&lang=en&year=${YEAR}` + ); + }); + test("monthly export", async ({ page }) => { + await page.goto("/#/sessions?&year=2023&month=5"); + await expect(page.getByRole("link", { name: "Download May 2023 CSV" })).toHaveAttribute( + "href", + "./api/sessions?format=csv&lang=en&year=2023&month=5" + ); + + // current month + await page.goto(`/#/sessions`); + await expect( + page.getByRole("link", { name: `Download ${MONTH_YEAR} CSV` }) + ).toHaveAttribute("href", `./api/sessions?format=csv&lang=en&year=${YEAR}&month=${MONTH}`); + }); +}); + +test.describe("session details", async () => { + test("show session details (session 5)", async ({ page }) => { + await page.goto("/#/sessions?year=2023&month=5"); + await page.getByTestId("sessions-entry").nth(0).click(); + await expect(page.getByTestId("session-details")).toBeVisible(); + + await expect( + page.getByTestId("session-details").getByRole("heading", { name: "Charging Session" }) + ).toBeVisible(); + await expect(page.getByTestId("session-details-loadpoint")).toContainText("Garage"); + await expect(page.getByTestId("session-details-vehicle")).toContainText("weißes Model 3"); + await expect(page.getByTestId("session-details-date")).toContainText( + ["Thu, May 4, 22:00", "Fri, May 5, 06:00"].join("") + ); + await expect(page.getByTestId("session-details-energy")).toContainText("5.0 kWh"); + await expect(page.getByTestId("session-details-energy")).toContainText("1:00"); + await expect(page.getByTestId("session-details-solar")).toContainText("0.0% (0.0 kWh)"); + await expect(page.getByTestId("session-details-price")).toContainText("2.50 € 50.0 ct/kWh"); + await expect(page.getByTestId("session-details-co2")).toHaveCount(0); + await expect(page.getByTestId("session-details-odometer")).toHaveCount(0); + await expect(page.getByTestId("session-details-meter")).toHaveCount(0); + await expect(page.getByTestId("session-details-delete")).toContainText("Delete"); + }); + + test("show session details with CO2 data (session 1)", async ({ page }) => { + await page.goto("/#/sessions?year=2023&month=3"); + await page.getByTestId("sessions-entry").nth(0).click(); + await expect(page.getByTestId("session-details")).toBeVisible(); + + await expect(page.getByTestId("session-details-loadpoint")).toContainText("Carport"); + await expect(page.getByTestId("session-details-vehicle")).toContainText("blauer e-Golf"); + await expect(page.getByTestId("session-details-date")).toContainText( + ["Wed, Mar 1, 07:00", "Tue, May 2, 12:00"].join("") + ); + await expect(page.getByTestId("session-details-energy")).toContainText("10.0 kWh"); + await expect(page.getByTestId("session-details-energy")).toContainText("1:00"); + await expect(page.getByTestId("session-details-solar")).toContainText("100.0% (10.0 kWh)"); + await expect(page.getByTestId("session-details-price")).toContainText("2.00 € 20.0 ct/kWh"); + await expect(page.getByTestId("session-details-co2")).toContainText("300 g/kWh"); + }); +}); diff --git a/tests/simulator.js b/tests/simulator.js deleted file mode 100644 index 67bcfd527..000000000 --- a/tests/simulator.js +++ /dev/null @@ -1,77 +0,0 @@ -import os from "os"; -import path from "path"; -import fs from "fs"; -import waitOn from "wait-on"; -import axios from "axios"; -import { spawn } from "child_process"; -import { Transform } from "stream"; - -function workerPort() { - const index = process.env.TEST_WORKER_INDEX * 1; - return 12000 + index; -} - -function logPrefix() { - return `[worker:${process.env.TEST_WORKER_INDEX}]`; -} - -function createSteamLog() { - return new Transform({ - transform(chunk, encoding, callback) { - const lines = chunk.toString().split("\n"); - lines.forEach((line) => { - if (line.trim()) log(line); - }); - callback(); - }, - }); -} - -function log(...args) { - console.log(logPrefix(), ...args); -} - -export function simulatorHost() { - return `localhost:${workerPort()}`; -} - -export function simulatorUrl() { - return `http://${simulatorHost()}`; -} - -export function simulatorConfig() { - const input = "./tests/simulator.evcc.yaml"; - const content = fs.readFileSync(input, "utf8"); - const result = content.replace(/localhost:7072/g, simulatorHost()); - const resultName = "simulator.evcc.generated.yaml"; - const resultPath = path.join(os.tmpdir(), resultName); - fs.writeFileSync(resultPath, result); - return resultPath; -} - -export async function startSimulator() { - const port = workerPort(); - log("starting simulator", { port }); - log(`wait until port ${port} is available`); - await waitOn({ resources: [`tcp:${port}`], reverse: true, log: true }); - - const instance = spawn("npm", ["run", "simulator", "--", "--port", port]); - - const steamLog = createSteamLog(); - instance.stdout.pipe(steamLog); - instance.stderr.pipe(steamLog); - instance.on("exit", (code) => { - log("simulator terminated", { code, port }); - steamLog.end(); - }); - - await waitOn({ resources: [`${simulatorUrl()}/api/state`], log: true }); -} - -export async function stopSimulator() { - const port = workerPort(); - log("shutting down simulator", { port }); - await axios.post(`${simulatorUrl()}/api/shutdown`); - log(`wait until port ${port} is closed`); - await waitOn({ resources: [`tcp:localhost:${port}`], reverse: true }); -} diff --git a/tests/simulator.ts b/tests/simulator.ts new file mode 100644 index 000000000..0185e3e34 --- /dev/null +++ b/tests/simulator.ts @@ -0,0 +1,77 @@ +import os from "os"; +import path from "path"; +import fs from "fs"; +import waitOn from "wait-on"; +import axios from "axios"; +import { spawn } from "child_process"; +import { Transform } from "stream"; + +function workerPort() { + const index = parseInt(process.env["TEST_WORKER_INDEX"] ?? "-1"); + return 12000 + index; +} + +function logPrefix() { + return `[worker:${process.env["TEST_WORKER_INDEX"]}]`; +} + +function createSteamLog() { + return new Transform({ + transform(chunk: Buffer, _, callback) { + const lines = chunk.toString().split("\n"); + lines.forEach((line) => { + if (line.trim()) log(line); + }); + callback(); + }, + }); +} + +function log(...args: any[]) { + console.log(logPrefix(), ...args); +} + +export function simulatorHost() { + return `localhost:${workerPort()}`; +} + +export function simulatorUrl() { + return `http://${simulatorHost()}`; +} + +export function simulatorConfig() { + const input = "./tests/simulator.evcc.yaml"; + const content = fs.readFileSync(input, "utf8"); + const result = content.replace(/localhost:7072/g, simulatorHost()); + const resultName = "simulator.evcc.generated.yaml"; + const resultPath = path.join(os.tmpdir(), resultName); + fs.writeFileSync(resultPath, result); + return resultPath; +} + +export async function startSimulator() { + const port = workerPort(); + log("starting simulator", { port }); + log(`wait until port ${port} is available`); + await waitOn({ resources: [`tcp:${port}`], reverse: true, log: true }); + + const instance = spawn("npm", ["run", "simulator", "--", "--port", port.toString()]); + + const steamLog = createSteamLog(); + instance.stdout.pipe(steamLog); + instance.stderr.pipe(steamLog); + instance.on("exit", (code) => { + log("simulator terminated", { code, port }); + steamLog.end(); + }); + + await waitOn({ resources: [`${simulatorUrl()}/api/state`], log: true }); +} + +export async function stopSimulator() { + const port = workerPort(); + log("shutting down simulator", { port }); + await axios.post(`${simulatorUrl()}/api/shutdown`); + log(`wait until port ${port} is closed`); + await waitOn({ resources: [`tcp:localhost:${port}`], reverse: true }); +} diff --git a/tests/simulator/api.js b/tests/simulator/api.js deleted file mode 100644 index ae0fb31de..000000000 --- a/tests/simulator/api.js +++ /dev/null @@ -1,103 +0,0 @@ -import bodyParser from "body-parser"; - -let state = { - site: { - grid: { power: 0 }, - pv: { power: 0 }, - battery: { power: 0, soc: 0 }, - }, - loadpoints: [{ power: 0, energy: 0, enabled: false, status: "A" }], - vehicles: [{ soc: 0, range: 0 }], -}; - -const loggingMiddleware = (req, res, next) => { - console.log(`[simulator] ${req.method} ${req.originalUrl}`); - next(); -}; - -const stateApiMiddleware = (req, res, next) => { - if (req.method === "POST" && req.originalUrl === "/api/state") { - console.log("[simulator] POST /api/state"); - state = req.body; - res.end(); - } else if (req.method === "POST" && req.originalUrl === "/api/shutdown") { - console.log("[simulator] POST /api/shutdown"); - res.end(); - process.exit(); - } else if (req.originalUrl === "/api/state") { - res.end(JSON.stringify(state)); - } else { - next(); - } -}; - -const openemsMiddleware = (req, res, next) => { - const endpoints = { - "/rest/channel/_sum/GridActivePower": { value: state.site.grid.power }, - "/rest/channel/_sum/ProductionActivePower": { value: state.site.pv.power }, - "/rest/channel/_sum/EssDischargePower": { value: state.site.battery.power }, - "/rest/channel/_sum/EssSoc": { value: state.site.battery.soc }, - }; - const endpoint = endpoints[req.originalUrl]; - if (req.method === "GET" && endpoint) { - console.log("[simulator] GET", req.originalUrl); - res.end(JSON.stringify(endpoint)); - } else { - next(); - } -}; - -const teslaloggerMiddleware = (req, res, next) => { - if (req.method === "GET" && req.originalUrl.startsWith("/currentjson/")) { - const id = parseInt(req.originalUrl.split("/")[2]); - const vehicle = state.vehicles[id - 1]; - if (!vehicle) { - res.statusCode = 404; - res.end(JSON.stringify({ error: "Vehicle not found" })); - return; - } - const data = { - battery_level: vehicle.soc, - battery_range_km: vehicle.range, - plugged_in: true, - charging: false, - odometer: 10000, - is_preconditioning: false, - charge_current_request: 10, - }; - res.end(JSON.stringify(data)); - } else { - next(); - } -}; - -const shellyMiddleware = (req, res, next) => { - // simulate a shelly gen2 switch device api. implement power and energy - if (req.originalUrl === "/shelly") { - res.end(JSON.stringify({ gen: 2 })); - } else if (req.originalUrl === "/rpc/Shelly.ListMethods") { - res.end(JSON.stringify({ methods: ["Switch.GetStatus"] })); - } else if (req.originalUrl === "/rpc/Switch.GetStatus?id=0") { - res.end( - JSON.stringify({ apower: state.site.pv.power, aenergy: { total: state.site.pv.energy } }) - ); - } else { - next(); - } -}; - -export default () => ({ - name: "api", - enforce: "pre", - configureServer(server) { - console.log("[simulator] configured"); - return () => { - server.middlewares.use(loggingMiddleware); - server.middlewares.use(bodyParser.json()); - server.middlewares.use(stateApiMiddleware); - server.middlewares.use(openemsMiddleware); - server.middlewares.use(teslaloggerMiddleware); - server.middlewares.use(shellyMiddleware); - }; - }, -}); diff --git a/tests/simulator/api.ts b/tests/simulator/api.ts new file mode 100644 index 000000000..7ff330c06 --- /dev/null +++ b/tests/simulator/api.ts @@ -0,0 +1,129 @@ +import bodyParser from "body-parser"; +import type { Connect, ViteDevServer } from "vite"; +import type { ServerResponse } from "http"; + +let state = { + site: { + grid: { power: 0 }, + pv: { power: 0, energy: 0 }, + battery: { power: 0, soc: 0 }, + }, + loadpoints: [{ power: 0, energy: 0, enabled: false, status: "A" }], + vehicles: [{ soc: 0, range: 0 }], +}; + +const loggingMiddleware = ( + req: Connect.IncomingMessage, + _: ServerResponse, + next: Connect.NextFunction +) => { + console.log(`[simulator] ${req.method} ${req.url}`); + next(); +}; + +const stateApiMiddleware = ( + req: Connect.IncomingMessage, + res: ServerResponse, + next: Connect.NextFunction +) => { + if (req.method === "POST" && req.originalUrl === "/api/state") { + console.log("[simulator] POST /api/state"); + // @ts-expect-error Property 'body' does not exist on type 'IncomingMessage' + state = req.body; + res.end(); + } else if (req.method === "POST" && req.originalUrl === "/api/shutdown") { + console.log("[simulator] POST /api/shutdown"); + res.end(); + process.exit(); + } else if (req.originalUrl === "/api/state") { + res.end(JSON.stringify(state)); + } else { + next(); + } +}; + +const openemsMiddleware = ( + req: Connect.IncomingMessage, + res: ServerResponse, + next: Connect.NextFunction +) => { + const endpoints = { + "/rest/channel/_sum/GridActivePower": { value: state.site.grid.power }, + "/rest/channel/_sum/ProductionActivePower": { value: state.site.pv.power }, + "/rest/channel/_sum/EssDischargePower": { value: state.site.battery.power }, + "/rest/channel/_sum/EssSoc": { value: state.site.battery.soc }, + }; + const endpoint = endpoints[req.originalUrl as keyof typeof endpoints]; + if (req.method === "GET" && endpoint) { + console.log("[simulator] GET", req.originalUrl); + res.end(JSON.stringify(endpoint)); + } else { + next(); + } +}; + +const teslaloggerMiddleware = ( + req: Connect.IncomingMessage, + res: ServerResponse, + next: Connect.NextFunction +) => { + if (req.method === "GET" && req.originalUrl && req.originalUrl.startsWith("/currentjson/")) { + const id = parseInt(req.originalUrl.split("/")[2]); + const vehicle = state.vehicles[id - 1]; + if (!vehicle) { + res.statusCode = 404; + res.end(JSON.stringify({ error: "Vehicle not found" })); + return; + } + const data = { + battery_level: vehicle.soc, + battery_range_km: vehicle.range, + plugged_in: true, + charging: false, + odometer: 10000, + is_preconditioning: false, + charge_current_request: 10, + }; + res.end(JSON.stringify(data)); + } else { + next(); + } +}; + +const shellyMiddleware = ( + req: Connect.IncomingMessage, + res: ServerResponse, + next: Connect.NextFunction +) => { + // simulate a shelly gen2 switch device api. implement power and energy + if (req.originalUrl === "/shelly") { + res.end(JSON.stringify({ gen: 2 })); + } else if (req.originalUrl === "/rpc/Shelly.ListMethods") { + res.end(JSON.stringify({ methods: ["Switch.GetStatus"] })); + } else if (req.originalUrl === "/rpc/Switch.GetStatus?id=0") { + res.end( + JSON.stringify({ + apower: state.site.pv.power, + aenergy: { total: state.site.pv.energy }, + }) + ); + } else { + next(); + } +}; + +export default () => ({ + name: "api", + enforce: "pre", + configureServer(server: ViteDevServer) { + console.log("[simulator] configured"); + return () => { + server.middlewares.use(loggingMiddleware); + server.middlewares.use(bodyParser.json()); + server.middlewares.use(stateApiMiddleware); + server.middlewares.use(openemsMiddleware); + server.middlewares.use(teslaloggerMiddleware); + server.middlewares.use(shellyMiddleware); + }; + }, +}); diff --git a/tests/simulator/src/Simulator.vue b/tests/simulator/src/Simulator.vue index 0b647c878..045849869 100644 --- a/tests/simulator/src/Simulator.vue +++ b/tests/simulator/src/Simulator.vue @@ -239,14 +239,28 @@ -