diff --git a/assets/js/components/Config/InvalidReferenceAlert.vue b/assets/js/components/Config/InvalidReferenceAlert.vue new file mode 100644 index 000000000..ff8baa9f5 --- /dev/null +++ b/assets/js/components/Config/InvalidReferenceAlert.vue @@ -0,0 +1,26 @@ + + + diff --git a/assets/js/components/Config/LoadpointModal.vue b/assets/js/components/Config/LoadpointModal.vue index 775b14005..86c7f2b95 100644 --- a/assets/js/components/Config/LoadpointModal.vue +++ b/assets/js/components/Config/LoadpointModal.vue @@ -413,26 +413,40 @@ - - + - + :label="$t('config.loadpoint.circuitLabel')" + :help="$t('config.loadpoint.circuitHelp')" + > + + + +
{{ $t("config.loadpoint.vehiclesTitle") }}
-
+ +
[] }, + vehicleOptions: { type: Array as PropType, default: () => [] }, loadpointCount: { type: Number, default: 0 }, fade: String as PropType, chargers: { type: Array as PropType, default: () => [] }, @@ -718,7 +742,11 @@ export default { ]; }, showCircuit() { - return this.circuits.length > 0; + return this.circuits.length > 0 || this.values.circuit !== ""; + }, + invalidCircuit() { + const { circuit } = this.values; + return circuit && !this.circuitOptions.some((c) => c.key === circuit); }, circuitOptions() { const options = this.circuits.map((c) => ({ @@ -727,6 +755,10 @@ export default { })); return [{ key: "", name: "unassigned" }, ...options]; }, + invalidVehicle() { + const { vehicle } = this.values; + return vehicle && !this.vehicleOptions.some(({ key }) => key === vehicle); + }, allVehicleOptions() { return [ { key: "", name: this.$t("config.loadpoint.vehicleAutoDetection") }, diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 1d3569526..c6714a5cc 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -398,6 +398,11 @@ export interface Sponsor { fromYaml: boolean; } +export type VehicleOption = { + key?: string | null; + name: string | null; +}; + export enum MODBUS_BAUDRATE { _1200 = 1200, _9600 = 9600, diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index 2cad2aad0..892efa29c 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -454,6 +454,7 @@ import type { ConfigMeter, LoadpointType, Timeout, + VehicleOption, MeterType, SiteConfig, DeviceType, @@ -652,7 +653,7 @@ export default defineComponent({ if (org) result.org = { value: org }; return result; }, - vehicleOptions() { + vehicleOptions(): VehicleOption[] { return this.vehicles.map((v) => ({ key: v.name, name: v.config?.title || v.name })); }, shmTags(): DeviceTags { diff --git a/i18n/en.json b/i18n/en.json index 0007e3e29..3bdb8ba4f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -294,6 +294,7 @@ "chargerTypeLabel": "Charger type", "chargingTitle": "Behaviour", "circuitHelp": "Load management assignment to ensure power and current limits are not exceeded.", + "circuitInvalid": "Circuit does not exist", "circuitLabel": "Circuit", "circuitUnassigned": "unassigned", "defaultModeHelp": { @@ -370,6 +371,7 @@ "vehicleAutoDetection": "auto detection", "vehicleHelpAutoDetection": "Automatically selects the most plausible vehicle. Manual override is possible.", "vehicleHelpDefault": "Always assume this vehicle is charging here. Auto-detection disabled. Manual override is possible.", + "vehicleInvalid": "Vehicle does not exist", "vehicleLabel": "Default vehicle", "vehiclesTitle": "Vehicles" }, diff --git a/tests/config-invalid-references-vehicle.evcc.yaml b/tests/config-invalid-references-vehicle.evcc.yaml new file mode 100644 index 000000000..d0c5c34b1 --- /dev/null +++ b/tests/config-invalid-references-vehicle.evcc.yaml @@ -0,0 +1,4 @@ +vehicles: + - name: car + type: offline + title: Legacy Vehicle diff --git a/tests/config-invalid-references.spec.ts b/tests/config-invalid-references.spec.ts new file mode 100644 index 000000000..ab4839ceb --- /dev/null +++ b/tests/config-invalid-references.spec.ts @@ -0,0 +1,162 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, restart, baseUrl } from "./evcc"; +import { + expectModalVisible, + expectModalHidden, + editorClear, + editorPaste, + addDemoCharger, + newLoadpoint, +} from "./utils"; + +test.use({ baseURL: baseUrl() }); +test.describe.configure({ mode: "parallel" }); + +test.afterEach(async () => { + await stop(); +}); + +test.describe("invalid references", async () => { + test("circuit", async ({ page }) => { + await start(); + await page.goto("/#/config"); + + // Create circuit via UI + await page.getByTestId("circuits").getByRole("button", { name: "edit" }).click(); + const circuitsModal = page.getByTestId("circuits-modal"); + await expectModalVisible(circuitsModal); + + const circuitEditor = circuitsModal.getByTestId("yaml-editor"); + await editorClear(circuitEditor); + await editorPaste( + circuitEditor, + page, + `- name: main + title: Main` + ); + + await circuitsModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(circuitsModal); + + // Restart + await restart(); + + // Create loadpoint with demo charger + const lpModal = page.getByTestId("loadpoint-modal"); + await newLoadpoint(page, "Test Carport"); + await addDemoCharger(page); + + // Wait for circuit field to be available and assign to circuit main + await expect(lpModal.getByLabel("Circuit")).toBeVisible(); + await lpModal.getByLabel("Circuit").selectOption("Main [main]"); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + + // Edit circuit and rename "main" to "main2" + await page.getByTestId("circuits").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(circuitsModal); + await editorClear(circuitEditor); + await editorPaste( + circuitEditor, + page, + `- name: main2 + title: Main` + ); + await circuitsModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(circuitsModal); + + // Save and restart + await restart(); + + // Check boot error + await expect(page.getByTestId("fatal-error")).toBeVisible(); + await expect(page.getByTestId("fatal-error")).toContainText("circuit: not found: main"); + + // Verify loadpoint tile has error class + const loadpointTile = page.getByTestId("loadpoint"); + await expect(loadpointTile).toBeVisible(); + await expect(loadpointTile).toHaveClass(/round-box--error/); + + // Edit loadpoint + await loadpointTile.getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + + // Verify circuit select is hidden + await expect(lpModal.getByLabel("Circuit")).not.toBeVisible(); + + // Verify invalid-reference-alert with correct text is visible + const alert = lpModal.getByTestId("invalid-reference-alert"); + await expect(alert).toBeVisible(); + await expect(alert).toContainText("Circuit does not exist: main"); + + // Click remove button + await alert.getByRole("link", { name: "Remove" }).click(); + + // Verify the circuit select is now available again + await expect(lpModal.getByLabel("Circuit")).toBeVisible(); + await expect(alert).not.toBeVisible(); + + // Save and restart + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + + await restart(); + + // Verify no error + await expect(page.getByTestId("fatal-error")).not.toBeVisible(); + await expect(loadpointTile).not.toHaveClass(/round-box--error/); + await expect(loadpointTile).toContainText("Test Carport"); + }); + + test("vehicle", async ({ page }) => { + // Start with YAML file containing one vehicle + await start("config-invalid-references-vehicle.evcc.yaml"); + await page.goto("/#/config"); + + const lpModal = page.getByTestId("loadpoint-modal"); + + // Create loadpoint with demo charger and assign vehicle + await newLoadpoint(page, "Garage"); + await addDemoCharger(page); + await expect(lpModal.getByLabel("Default vehicle")).toBeVisible(); + await lpModal.getByLabel("Default vehicle").selectOption("Legacy Vehicle"); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + + // Restart without YAML file (simulating user changed it) + await restart(); + + // Verify fatal error on boot + await expect(page.getByTestId("fatal-error")).toBeVisible(); + await expect(page.getByTestId("fatal-error")).toContainText("vehicle: not found: car"); + + // Verify loadpoint has error class + const loadpointTile = page.getByTestId("loadpoint"); + await expect(loadpointTile).toBeVisible(); + await expect(loadpointTile).toHaveClass(/round-box--error/); + + // Open loadpoint modal and verify invalid reference alert + await loadpointTile.getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + + const alert = lpModal.getByTestId("invalid-reference-alert"); + await expect(alert).toBeVisible(); + await expect(alert).toContainText("Vehicle does not exist: car"); + + // Remove vehicle reference + await alert.getByRole("link", { name: "Remove" }).click(); + await expect(alert).not.toBeVisible(); + + // Verify "no vehicles" message is shown + await expect(lpModal).toContainText("No vehicles are configured."); + + // Save and restart + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + await restart(); + + // Verify no fatal error and no error class + await expect(page.getByTestId("fatal-error")).not.toBeVisible(); + await expect(loadpointTile).not.toHaveClass(/round-box--error/); + }); +});