+
+
[] },
+ 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/);
+ });
+});