chore: fix npm run lint; spaces > tabs for .ts (#21879)

This commit is contained in:
Michael Geers 2025-06-17 17:14:55 +02:00 • committed by GitHub
parent 7c8b9f588e
commit 404c97b654
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 4813 additions and 4846 deletions

View file

@ -14,6 +14,6 @@ indent_size = 4
[*.css] [*.css]
indent_size = 2 indent_size = 2
[*.{js,html,yml,yaml,json,md}] [*.{js,html,yml,yaml,json,md,ts}]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2

1
.prettierignore Normal file
View file

@ -0,0 +1 @@
tests/custom-css.css

View file

@ -7,63 +7,61 @@ const base = protocol + "//" + hostname + (port ? ":" + port : "") + pathname;
// override the way axios serializes arrays in query parameters (a=1&a=2&a=3 instead of a[]=1&a[]=2&a[]=3) // override the way axios serializes arrays in query parameters (a=1&a=2&a=3 instead of a[]=1&a[]=2&a[]=3)
function customParamsSerializer(params: { [key: string]: any }) { function customParamsSerializer(params: { [key: string]: any }) {
return Object.keys(params) return Object.keys(params)
.filter((key) => params[key] !== null) .filter((key) => params[key] !== null)
.map((key) => { .map((key) => {
const value = params[key]; const value = params[key];
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value return value.map((v) => `${encodeURIComponent(key)}=${encodeURIComponent(v)}`).join("&");
.map((v) => `${encodeURIComponent(key)}=${encodeURIComponent(v)}`) }
.join("&"); return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
} })
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; .join("&");
})
.join("&");
} }
const api = axios.create({ const api = axios.create({
baseURL: base + "api/", baseURL: base + "api/",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
}, },
paramsSerializer: customParamsSerializer, paramsSerializer: customParamsSerializer,
}); });
// global error handling // global error handling
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
// handle unauthorized errors // handle unauthorized errors
if (error.response?.status === 401) { if (error.response?.status === 401) {
openLoginModal(); openLoginModal();
return Promise.reject(error); return Promise.reject(error);
} }
const message = [`${error.message}.`]; const message = [`${error.message}.`];
if (error.response?.data?.error) { if (error.response?.data?.error) {
message.push(`${error.response.data.error}.`); message.push(`${error.response.data.error}.`);
} }
if (error.config) { if (error.config) {
const method = error.config.method.toUpperCase(); const method = error.config.method.toUpperCase();
const url = error.request.responseURL; const url = error.request.responseURL;
message.push(`${method} ${url}`); message.push(`${method} ${url}`);
} }
window.app.raise({ message }); window.app.raise({ message });
return Promise.reject(error); return Promise.reject(error);
} }
); );
export default api; export default api;
export const i18n = axios.create({ export const i18n = axios.create({
baseURL: base + "i18n/", baseURL: base + "i18n/",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
}, },
}); });
export const allowClientError = { export const allowClientError = {
validateStatus(status: number) { validateStatus(status: number) {
return status >= 200 && status < 500; return status >= 200 && status < 500;
}, },
}; };

View file

@ -12,58 +12,58 @@ import type { Notification } from "./types/evcc";
// lazy load smoothscroll polyfill. mainly for safari < 15.4 // lazy load smoothscroll polyfill. mainly for safari < 15.4
if (!window.CSS.supports("scroll-behavior", "smooth")) { if (!window.CSS.supports("scroll-behavior", "smooth")) {
console.log("no native smoothscroll support. polyfilling..."); console.log("no native smoothscroll support. polyfilling...");
import("smoothscroll-polyfill").then((module) => { import("smoothscroll-polyfill").then((module) => {
module.polyfill(); module.polyfill();
}); });
} }
const app = createApp( const app = createApp(
defineComponent({ defineComponent({
data() { data() {
return { notifications: [] as Notification[], offline: false }; return { notifications: [] as Notification[], offline: false };
}, },
watch: { watch: {
offline(value) { offline(value) {
console.log(`we are ${value ? "offline" : "online"}`); console.log(`we are ${value ? "offline" : "online"}`);
}, },
}, },
methods: { methods: {
raise(msg: Notification) { raise(msg: Notification) {
if (this.offline) return; if (this.offline) return;
if (!msg.level) msg.level = "error"; if (!msg.level) msg.level = "error";
const now = new Date(); const now = new Date();
const latestMsg = this.notifications[0]; const latestMsg = this.notifications[0];
if (latestMsg && latestMsg.message === msg.message && latestMsg.lp === msg.lp) { if (latestMsg && latestMsg.message === msg.message && latestMsg.lp === msg.lp) {
latestMsg.count++; latestMsg.count++;
latestMsg.time = now; latestMsg.time = now;
} else { } else {
this.notifications = [ this.notifications = [
{ {
...msg, ...msg,
count: 1, count: 1,
time: now, time: now,
}, },
...this.notifications, ...this.notifications,
]; ];
} }
}, },
clear() { clear() {
this.notifications = []; this.notifications = [];
}, },
setOnline() { setOnline() {
this.offline = false; this.offline = false;
sendToApp({ type: "online" }); sendToApp({ type: "online" });
}, },
setOffline() { setOffline() {
this.offline = true; this.offline = true;
sendToApp({ type: "offline" }); sendToApp({ type: "offline" });
}, },
}, },
render() { render() {
return h(App, { notifications: this.notifications, offline: this.offline }); return h(App, { notifications: this.notifications, offline: this.offline });
}, },
}) })
); );
const i18n = setupI18n(); const i18n = setupI18n();
@ -80,8 +80,8 @@ watchThemeChanges();
appDetection(); appDetection();
if (window.evcc.customCss === "true") { if (window.evcc.customCss === "true") {
const link = document.createElement("link"); const link = document.createElement("link");
link.href = `./custom.css`; link.href = `./custom.css`;
link.rel = "stylesheet"; link.rel = "stylesheet";
document.head.appendChild(link); document.head.appendChild(link);
} }

View file

@ -8,79 +8,79 @@ import { reactive } from "vue";
// const COLORS = [ "#0077B6FF", "#00B4D8FF", "#90E0EFFF", "#40A578FF", "#9DDE8BFF", "#F8961EFF", "#F9C74FFF", "#E6FF94FF"]; // const COLORS = [ "#0077B6FF", "#00B4D8FF", "#90E0EFFF", "#40A578FF", "#9DDE8BFF", "#F8961EFF", "#F9C74FFF", "#E6FF94FF"];
const colors: { const colors: {
text: string | null; text: string | null;
muted: string | null; muted: string | null;
border: string | null; border: string | null;
self: string | null; self: string | null;
grid: string | null; grid: string | null;
co2PerKWh: string | null; co2PerKWh: string | null;
pricePerKWh: string | null; pricePerKWh: string | null;
price: string | null; price: string | null;
co2: string | null; co2: string | null;
background: string | null; background: string | null;
light: string | null; light: string | null;
selfPalette: string[]; selfPalette: string[];
palette: string[]; palette: string[];
} = reactive({ } = reactive({
text: null, text: null,
muted: null, muted: null,
border: null, border: null,
self: null, self: null,
grid: null, grid: null,
co2PerKWh: null, co2PerKWh: null,
pricePerKWh: null, pricePerKWh: null,
price: null, price: null,
co2: null, co2: null,
background: null, background: null,
light: null, light: null,
selfPalette: ["#0FDE41FF", "#FFBD2FFF", "#FD6158FF", "#03C1EFFF", "#0F662DFF", "#FF922EFF"], selfPalette: ["#0FDE41FF", "#FFBD2FFF", "#FD6158FF", "#03C1EFFF", "#0F662DFF", "#FF922EFF"],
palette: [ palette: [
"#03C1EFFF", "#03C1EFFF",
"#FD6158FF", "#FD6158FF",
"#31AB4AFF", "#31AB4AFF",
"#41517AFF", "#41517AFF",
"#FF922EFF", "#FF922EFF",
"#0F662DFF", "#0F662DFF",
"#0470D4FF", "#0470D4FF",
"#FFBD2FFF", "#FFBD2FFF",
"#77C93EFF", "#77C93EFF",
"#4E1D10FF", "#4E1D10FF",
"#0AAFBFFF", "#0AAFBFFF",
"#813504FF", "#813504FF",
], ],
}); });
export const dimColor = (color: string | null) => { export const dimColor = (color: string | null) => {
return color?.toLowerCase().replace(/ff$/, "20"); return color?.toLowerCase().replace(/ff$/, "20");
}; };
export const lighterColor = (color: string | null) => { export const lighterColor = (color: string | null) => {
return color?.toLowerCase().replace(/ff$/, "aa"); return color?.toLowerCase().replace(/ff$/, "aa");
}; };
export const fullColor = (color: string | null) => { export const fullColor = (color: string | null) => {
return color?.toLowerCase().replace(/20$/, "ff"); return color?.toLowerCase().replace(/20$/, "ff");
}; };
function updateCssColors() { function updateCssColors() {
const style = window.getComputedStyle(document.documentElement); const style = window.getComputedStyle(document.documentElement);
colors.text = style.getPropertyValue("--evcc-default-text"); colors.text = style.getPropertyValue("--evcc-default-text");
colors.muted = style.getPropertyValue("--bs-gray-medium"); colors.muted = style.getPropertyValue("--bs-gray-medium");
colors.border = style.getPropertyValue("--bs-border-color-translucent"); colors.border = style.getPropertyValue("--bs-border-color-translucent");
colors.self = style.getPropertyValue("--evcc-self"); colors.self = style.getPropertyValue("--evcc-self");
colors.grid = style.getPropertyValue("--evcc-grid"); colors.grid = style.getPropertyValue("--evcc-grid");
colors.price = style.getPropertyValue("--evcc-price"); colors.price = style.getPropertyValue("--evcc-price");
colors.co2 = style.getPropertyValue("--evcc-co2"); colors.co2 = style.getPropertyValue("--evcc-co2");
colors.background = style.getPropertyValue("--evcc-background"); colors.background = style.getPropertyValue("--evcc-background");
colors.pricePerKWh = style.getPropertyValue("--bs-gray-medium"); colors.pricePerKWh = style.getPropertyValue("--bs-gray-medium");
colors.co2PerKWh = style.getPropertyValue("--bs-gray-medium"); colors.co2PerKWh = style.getPropertyValue("--bs-gray-medium");
colors.light = style.getPropertyValue("--bs-gray-light"); colors.light = style.getPropertyValue("--bs-gray-light");
} }
// update colors on theme change // update colors on theme change
const darkModeMatcher = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)"); const darkModeMatcher = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
if (darkModeMatcher && darkModeMatcher.addEventListener) { if (darkModeMatcher && darkModeMatcher.addEventListener) {
darkModeMatcher.addEventListener("change", updateCssColors); darkModeMatcher.addEventListener("change", updateCssColors);
} }
// initialize colors // initialize colors
updateCssColors(); updateCssColors();

View file

@ -5,101 +5,101 @@ import Modal from "bootstrap/js/dist/modal";
import { isSystemError } from "@/utils/fatal.js"; import { isSystemError } from "@/utils/fatal.js";
const auth = reactive({ const auth = reactive({
configured: true, configured: true,
loggedIn: null as boolean | null, // true / false / null (unknown) loggedIn: null as boolean | null, // true / false / null (unknown)
nextUrl: null as string | null, // url to navigate to after login nextUrl: null as string | null, // url to navigate to after login
nextModal: null as Modal | null, // modal instance to show after login nextModal: null as Modal | null, // modal instance to show after login
}); });
export async function updateAuthStatus() { export async function updateAuthStatus() {
if (store.state.offline || isSystemError(store.state.fatal)) { if (store.state.offline || isSystemError(store.state.fatal)) {
// system not ready, skip auth check // system not ready, skip auth check
return; return;
} }
try { try {
const res = await api.get("/auth/status", { const res = await api.get("/auth/status", {
validateStatus: (code) => [200, 501, 500].includes(code), validateStatus: (code) => [200, 501, 500].includes(code),
}); });
if (res.status === 501) { if (res.status === 501) {
auth.configured = false; auth.configured = false;
} }
if (res.status === 200) { if (res.status === 200) {
auth.configured = true; auth.configured = true;
auth.loggedIn = res.data === true; auth.loggedIn = res.data === true;
} }
if (res.status === 500) { if (res.status === 500) {
auth.loggedIn = null; auth.loggedIn = null;
console.log("unable to fetch auth status", res); console.log("unable to fetch auth status", res);
} }
} catch (e) { } catch (e) {
console.log("unable to fetch auth status", e); console.log("unable to fetch auth status", e);
} }
} }
export async function logout() { export async function logout() {
try { try {
await api.post("/auth/logout"); await api.post("/auth/logout");
await updateAuthStatus(); await updateAuthStatus();
auth.nextUrl = null; auth.nextUrl = null;
} catch (e) { } catch (e) {
console.log("unable to logout", e); console.log("unable to logout", e);
} }
} }
export function isLoggedIn() { export function isLoggedIn() {
return auth.loggedIn === true; return auth.loggedIn === true;
} }
export function statusUnknown() { export function statusUnknown() {
return auth.loggedIn === null; return auth.loggedIn === null;
} }
export function isConfigured() { export function isConfigured() {
return auth.configured; return auth.configured;
} }
export function getAndClearNextUrl() { export function getAndClearNextUrl() {
const nextUrl = auth.nextUrl; const nextUrl = auth.nextUrl;
auth.nextUrl = null; auth.nextUrl = null;
return nextUrl; return nextUrl;
} }
export function getAndClearNextModal() { export function getAndClearNextModal() {
const nextModal = auth.nextModal; const nextModal = auth.nextModal;
auth.nextModal = null; auth.nextModal = null;
return nextModal; return nextModal;
} }
export function openLoginModal(nextUrl: string | null = null, nextModal: Modal | null = null) { export function openLoginModal(nextUrl: string | null = null, nextModal: Modal | null = null) {
auth.nextUrl = nextUrl; auth.nextUrl = nextUrl;
auth.nextModal = nextModal; auth.nextModal = nextModal;
const modal = Modal.getOrCreateInstance(document.getElementById("loginModal") as HTMLElement); const modal = Modal.getOrCreateInstance(document.getElementById("loginModal") as HTMLElement);
modal.show(); modal.show();
} }
// show/hide password modal based on auth status // show/hide password modal based on auth status
watch( watch(
() => auth.configured, () => auth.configured,
(configured) => { (configured) => {
console.log("configured", configured); console.log("configured", configured);
const modal = Modal.getOrCreateInstance( const modal = Modal.getOrCreateInstance(
document.getElementById("passwordModal") as HTMLElement document.getElementById("passwordModal") as HTMLElement
); );
if (configured) { if (configured) {
modal.hide(); modal.hide();
} else { } else {
modal.show(); modal.show();
} }
} }
); );
let timeoutId: number | undefined = undefined; let timeoutId: number | undefined = undefined;
function debounedUpdateAuthStatus() { function debounedUpdateAuthStatus() {
clearTimeout(timeoutId); clearTimeout(timeoutId);
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
updateAuthStatus(); updateAuthStatus();
}, 500) as unknown as number; }, 500) as unknown as number;
} }
// update auth status on reconnect or server restart // update auth status on reconnect or server restart

View file

@ -2,55 +2,55 @@ import ChargingPlan from "./ChargingPlan.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
const hoursFromNow = (hours: number) => { const hoursFromNow = (hours: number) => {
const now = new Date(); const now = new Date();
now.setHours(now.getHours() + hours); now.setHours(now.getHours() + hours);
return now.toISOString(); return now.toISOString();
}; };
export default { export default {
title: "ChargingPlans/ChargingPlan", title: "ChargingPlans/ChargingPlan",
component: ChargingPlan, component: ChargingPlan,
parameters: { parameters: {
layout: "centered", layout: "centered",
}, },
argTypes: { argTypes: {
id: { control: "text" }, id: { control: "text" },
effectivePlanTime: { control: "text" }, effectivePlanTime: { control: "text" },
effectivePlanSoc: { control: "number" }, effectivePlanSoc: { control: "number" },
planEnergy: { control: "number" }, planEnergy: { control: "number" },
socBasedCharging: { control: "boolean" }, socBasedCharging: { control: "boolean" },
socBasedPlanning: { control: "boolean" }, socBasedPlanning: { control: "boolean" },
vehicleSoc: { control: "number" }, vehicleSoc: { control: "number" },
}, },
} as Meta<typeof ChargingPlan>; } as Meta<typeof ChargingPlan>;
const Template: StoryFn<typeof ChargingPlan> = (args) => ({ const Template: StoryFn<typeof ChargingPlan> = (args) => ({
components: { ChargingPlan }, components: { ChargingPlan },
setup() { setup() {
return { args }; return { args };
}, },
template: '<ChargingPlan v-bind="args" />', template: '<ChargingPlan v-bind="args" />',
}); });
export const None = Template.bind({}); export const None = Template.bind({});
None.args = { None.args = {
id: "1", id: "1",
}; };
export const SocBasedCharging = Template.bind({}); export const SocBasedCharging = Template.bind({});
SocBasedCharging.args = { SocBasedCharging.args = {
id: "1", id: "1",
effectivePlanTime: hoursFromNow(4), effectivePlanTime: hoursFromNow(4),
effectivePlanSoc: 77, effectivePlanSoc: 77,
socBasedCharging: true, socBasedCharging: true,
socBasedPlanning: true, socBasedPlanning: true,
}; };
export const EnergyBasedCharging = Template.bind({}); export const EnergyBasedCharging = Template.bind({});
EnergyBasedCharging.args = { EnergyBasedCharging.args = {
id: "1", id: "1",
effectivePlanTime: hoursFromNow(12), effectivePlanTime: hoursFromNow(12),
planEnergy: 77, planEnergy: 77,
socBasedCharging: false, socBasedCharging: false,
socBasedPlanning: false, socBasedPlanning: false,
}; };

View file

@ -5,100 +5,99 @@ import type { StoryFn } from "@storybook/vue3";
const now = new Date(); const now = new Date();
function createDate(hoursFromNow: number) { function createDate(hoursFromNow: number) {
const result = new Date(now.getTime()); const result = new Date(now.getTime());
result.setHours(result.getHours() + hoursFromNow); result.setHours(result.getHours() + hoursFromNow);
return result; return result;
} }
function createRate(value: number, hoursFromNow: number, durationHours = 1): Rate { function createRate(value: number, hoursFromNow: number, durationHours = 1): Rate {
const start = new Date(now.getTime()); const start = new Date(now.getTime());
start.setHours(start.getHours() + hoursFromNow); start.setHours(start.getHours() + hoursFromNow);
start.setMinutes(0); start.setMinutes(0);
start.setSeconds(0); start.setSeconds(0);
start.setMilliseconds(0); start.setMilliseconds(0);
const end = new Date(start.getTime()); const end = new Date(start.getTime());
end.setHours(start.getHours() + durationHours); end.setHours(start.getHours() + durationHours);
end.setMinutes(0); end.setMinutes(0);
end.setSeconds(0); end.setSeconds(0);
end.setMilliseconds(0); end.setMilliseconds(0);
return { start, end, value }; return { start, end, value };
} }
// Scenario data // Scenario data
const co2Data = { const co2Data = {
rates: [ rates: [
545, 518, 545, 518, 0, 545, 527, 527, 536, 518, 400, 336, 336, 339, 344, 336, 336, 336, 372, 545, 518, 545, 518, 0, 545, 527, 527, 536, 518, 400, 336, 336, 339, 344, 336, 336, 336, 372,
400, 555, 555, 545, 555, 564, 545, 555, 545, 536, 545, 527, 536, 518, 545, 509, 336, 336, 400, 555, 555, 545, 555, 564, 545, 555, 545, 536, 545, 527, 536, 518, 545, 509, 336, 336, 336,
336, ].map((value, i) => createRate(value, i)),
].map((value, i) => createRate(value, i)), duration: 8695,
duration: 8695, plan: [createRate(213, 4), createRate(336, 11), createRate(336, 12)],
plan: [createRate(213, 4), createRate(336, 11), createRate(336, 12)], smartCostType: "co2",
smartCostType: "co2", targetTime: createDate(14),
targetTime: createDate(14),
}; };
const fixedData = { const fixedData = {
rates: [createRate(0.442, 0, 50)], rates: [createRate(0.442, 0, 50)],
duration: 8695, duration: 8695,
plan: [createRate(0.442, 12, 3)], plan: [createRate(0.442, 12, 3)],
smartCostType: "price", smartCostType: "price",
currency: CURRENCY.EUR, currency: CURRENCY.EUR,
targetTime: createDate(14), targetTime: createDate(14),
}; };
const zonedData = { const zonedData = {
rates: [ rates: [
createRate(3.72, 0, 4), createRate(3.72, 0, 4),
createRate(2.39, 4, 12), createRate(2.39, 4, 12),
createRate(3.72, 16, 12), createRate(3.72, 16, 12),
createRate(2.39, 28, 12), createRate(2.39, 28, 12),
createRate(3.72, 40, 12), createRate(3.72, 40, 12),
], ],
duration: 8695, duration: 8695,
plan: [createRate(2.39, 13, 3)], plan: [createRate(2.39, 13, 3)],
smartCostType: "price", smartCostType: "price",
currency: CURRENCY.DKK, currency: CURRENCY.DKK,
targetTime: createDate(17), targetTime: createDate(17),
}; };
const unknownData = { const unknownData = {
rates: co2Data.rates.slice(0, 16), rates: co2Data.rates.slice(0, 16),
duration: 8695, duration: 8695,
plan: [createRate(213, 4), createRate(336, 11), createRate(336, 12)], plan: [createRate(213, 4), createRate(336, 11), createRate(336, 12)],
smartCostType: "co2", smartCostType: "co2",
targetTime: createDate(14), targetTime: createDate(14),
}; };
const dynamicData = { const dynamicData = {
rates: [ rates: [0.12, 0.15, 0, -0.05, -0.11, -0.24, -0.08, 0.12, 0.25, 0.29, 0.22, 0.31, 0.31, 0.33].map(
0.12, 0.15, 0, -0.05, -0.11, -0.24, -0.08, 0.12, 0.25, 0.29, 0.22, 0.31, 0.31, 0.33, (value, i) => createRate(value, i)
].map((value, i) => createRate(value, i)), ),
duration: 8695, duration: 8695,
plan: [createRate(0.23, 2, 5)], plan: [createRate(0.23, 2, 5)],
smartCostType: "price", smartCostType: "price",
currency: CURRENCY.EUR, currency: CURRENCY.EUR,
targetTime: createDate(13), targetTime: createDate(13),
}; };
export default { export default {
title: "ChargingPlans/Preview", title: "ChargingPlans/Preview",
component: Preview, component: Preview,
argTypes: { argTypes: {
rates: { control: "object" }, rates: { control: "object" },
duration: { control: "number" }, duration: { control: "number" },
plan: { control: "object" }, plan: { control: "object" },
smartCostType: { control: "select", options: ["co2", "price"] }, smartCostType: { control: "select", options: ["co2", "price"] },
currency: { control: "text" }, currency: { control: "text" },
targetTime: { control: "text" }, targetTime: { control: "text" },
}, },
}; };
const Template: StoryFn<typeof Preview> = (args) => ({ const Template: StoryFn<typeof Preview> = (args) => ({
components: { Preview }, components: { Preview },
setup() { setup() {
return { args }; return { args };
}, },
template: '<Preview v-bind="args" />', template: '<Preview v-bind="args" />',
}); });
export const Co2 = Template.bind({}); export const Co2 = Template.bind({});

View file

@ -7,134 +7,134 @@ config.global.mocks["$i18n"] = { locale: "de-DE" };
config.global.mocks["$t"] = (a: any) => a; config.global.mocks["$t"] = (a: any) => a;
describe("basics", () => { describe("basics", () => {
const DATE_START = new Date("2023-01-11T11:00:00+01:00"); const DATE_START = new Date("2023-01-11T11:00:00+01:00");
const DATE_TARGET = new Date("2023-01-11T13:00:00+01:00"); const DATE_TARGET = new Date("2023-01-11T13:00:00+01:00");
const TARIFF_FIXED = [ const TARIFF_FIXED = [
{ {
start: new Date("2023-01-11T11:00:00+01:00"), start: new Date("2023-01-11T11:00:00+01:00"),
end: new Date("2023-01-22T00:00:00+01:00"), end: new Date("2023-01-22T00:00:00+01:00"),
value: 0.4, value: 0.4,
}, },
]; ];
const PLAN = [ const PLAN = [
{ {
start: new Date("2023-01-11T12:00:00+01:00"), start: new Date("2023-01-11T12:00:00+01:00"),
end: new Date("2023-01-11T13:00:00+01:00"), end: new Date("2023-01-11T13:00:00+01:00"),
value: 0.2, value: 0.2,
}, },
]; ];
const wrapper = mount(Preview, { const wrapper = mount(Preview, {
props: { props: {
plan: PLAN, plan: PLAN,
targetTime: DATE_TARGET, targetTime: DATE_TARGET,
rates: TARIFF_FIXED, rates: TARIFF_FIXED,
}, },
}); });
wrapper.setData({ startTime: DATE_START }); wrapper.setData({ startTime: DATE_START });
let result: Slot[]; let result: Slot[];
beforeAll(() => { beforeAll(() => {
result = wrapper.vm.slots; result = wrapper.vm.slots;
}); });
test("should return 39 slots", () => { test("should return 39 slots", () => {
expect(result.length).eq(39); expect(result.length).eq(39);
}); });
test("slots should be an hour apart", () => { test("slots should be an hour apart", () => {
expect(result[0].startHour).eq(11); expect(result[0].startHour).eq(11);
expect(result[0].endHour).eq(12); expect(result[0].endHour).eq(12);
expect(result[0].day).eq("Mi"); expect(result[0].day).eq("Mi");
expect(result[1].startHour).eq(12); expect(result[1].startHour).eq(12);
expect(result[1].endHour).eq(13); expect(result[1].endHour).eq(13);
expect(result[1].day).eq("Mi"); expect(result[1].day).eq("Mi");
expect(result[11].startHour).eq(22); expect(result[11].startHour).eq(22);
expect(result[11].endHour).eq(23); expect(result[11].endHour).eq(23);
expect(result[11].day).eq("Mi"); expect(result[11].day).eq("Mi");
expect(result[24].startHour).eq(11); expect(result[24].startHour).eq(11);
expect(result[24].endHour).eq(12); expect(result[24].endHour).eq(12);
expect(result[24].day).eq("Do"); expect(result[24].day).eq("Do");
}); });
test("slots after target should be toLate", () => { test("slots after target should be toLate", () => {
expect(result[0].toLate).eq(false); expect(result[0].toLate).eq(false);
expect(result[1].toLate).eq(false); expect(result[1].toLate).eq(false);
expect(result[2].toLate).eq(true); expect(result[2].toLate).eq(true);
expect(result[3].toLate).eq(true); expect(result[3].toLate).eq(true);
}); });
test("slots are marked if charging is happening in them", () => { test("slots are marked if charging is happening in them", () => {
expect(result[0].charging).eq(false); expect(result[0].charging).eq(false);
expect(result[1].charging).eq(true); expect(result[1].charging).eq(true);
expect(result[2].charging).eq(false); expect(result[2].charging).eq(false);
expect(result[3].charging).eq(false); expect(result[3].charging).eq(false);
}); });
test("all slots have the same fixed value", () => { test("all slots have the same fixed value", () => {
result.forEach((slot) => expect(slot.value).eq(0.4)); result.forEach((slot) => expect(slot.value).eq(0.4));
}); });
}); });
describe("zoned tariffs", () => { describe("zoned tariffs", () => {
const DATE_START = new Date("2023-01-11T11:00:00+01:00"); const DATE_START = new Date("2023-01-11T11:00:00+01:00");
const DATE_TARGET = new Date("2023-01-11T16:00:00+01:00"); const DATE_TARGET = new Date("2023-01-11T16:00:00+01:00");
const TARIFF_ZONED = [ const TARIFF_ZONED = [
{ {
start: new Date("2023-01-11T11:00:00+01:00"), start: new Date("2023-01-11T11:00:00+01:00"),
end: new Date("2023-01-11T12:00:00+01:00"), end: new Date("2023-01-11T12:00:00+01:00"),
value: 0.2, value: 0.2,
}, },
{ {
start: new Date("2023-01-11T12:00:00+01:00"), start: new Date("2023-01-11T12:00:00+01:00"),
end: new Date("2023-01-22T00:00:00+01:00"), end: new Date("2023-01-22T00:00:00+01:00"),
value: 0.4, value: 0.4,
}, },
]; ];
const PLAN = [ const PLAN = [
{ {
start: new Date("2023-01-11T11:30:00+01:00"), start: new Date("2023-01-11T11:30:00+01:00"),
end: new Date("2023-01-11T13:00:00+01:00"), end: new Date("2023-01-11T13:00:00+01:00"),
value: 0.3, value: 0.3,
}, },
{ {
start: new Date("2023-01-11T14:30:00+01:00"), start: new Date("2023-01-11T14:30:00+01:00"),
end: new Date("2023-01-11T16:00:00+01:00"), end: new Date("2023-01-11T16:00:00+01:00"),
value: 0.2, value: 0.2,
}, },
]; ];
let result: Slot[]; let result: Slot[];
const wrapper = mount(Preview, { const wrapper = mount(Preview, {
props: { props: {
plan: PLAN, plan: PLAN,
targetTime: DATE_TARGET, targetTime: DATE_TARGET,
rates: TARIFF_ZONED, rates: TARIFF_ZONED,
}, },
}); });
wrapper.setData({ startTime: DATE_START }); wrapper.setData({ startTime: DATE_START });
beforeAll(() => { beforeAll(() => {
result = wrapper.vm.slots; result = wrapper.vm.slots;
}); });
test("handle multiple charging slots", () => { test("handle multiple charging slots", () => {
expect(result[0].charging).eq(true); expect(result[0].charging).eq(true);
expect(result[1].charging).eq(true); expect(result[1].charging).eq(true);
expect(result[2].charging).eq(false); expect(result[2].charging).eq(false);
expect(result[3].charging).eq(true); expect(result[3].charging).eq(true);
expect(result[4].charging).eq(true); expect(result[4].charging).eq(true);
expect(result[5].charging).eq(false); expect(result[5].charging).eq(false);
}); });
test("first slot is cheap, others are expensive", () => { test("first slot is cheap, others are expensive", () => {
const [first, ...others] = result; const [first, ...others] = result;
expect(first.value).eq(0.2); expect(first.value).eq(0.2);
others.forEach((slot) => expect(slot.value).eq(0.4)); others.forEach((slot) => expect(slot.value).eq(0.4));
}); });
}); });

View file

@ -1,37 +1,37 @@
import type { Rate } from "@/types/evcc"; import type { Rate } from "@/types/evcc";
export interface RepeatingPlan { export interface RepeatingPlan {
weekdays: number[]; weekdays: number[];
time: string; time: string;
tz: string; // timezone like "Europe/Berlin" tz: string; // timezone like "Europe/Berlin"
soc: number; soc: number;
active: boolean; active: boolean;
precondition: number; precondition: number;
} }
export interface PlanWrapper { export interface PlanWrapper {
planId: number; planId: number;
planTime: Date; planTime: Date;
duration: number; duration: number;
plan: Rate[]; plan: Rate[];
power: number; power: number;
} }
export interface PlanResponse { export interface PlanResponse {
status: number; status: number;
data: { result: PlanWrapper }; data: { result: PlanWrapper };
} }
export type StaticPlan = StaticSocPlan | StaticEnergyPlan; export type StaticPlan = StaticSocPlan | StaticEnergyPlan;
export interface StaticSocPlan { export interface StaticSocPlan {
soc: number; soc: number;
time: Date; time: Date;
precondition: number; precondition: number;
} }
export interface StaticEnergyPlan { export interface StaticEnergyPlan {
energy: number; energy: number;
time: Date; time: Date;
precondition: number; precondition: number;
} }

View file

@ -2,128 +2,128 @@ import type { DeviceType } from "@/types/evcc";
import api from "@/api"; import api from "@/api";
export type Product = { export type Product = {
group: string; group: string;
name: string; name: string;
template: string; template: string;
}; };
export type Template = { export type Template = {
Params: TemplateParam[]; Params: TemplateParam[];
Requirements: { Requirements: {
Description: string; Description: string;
}; };
}; };
export type TemplateType = "vehicle" | "battery" | "grid" | "pv" | "charger" | "aux" | "ext"; export type TemplateType = "vehicle" | "battery" | "grid" | "pv" | "charger" | "aux" | "ext";
export type TemplateParam = { export type TemplateParam = {
Name: string; Name: string;
Required: boolean; Required: boolean;
Advanced: boolean; Advanced: boolean;
Deprecated: boolean; Deprecated: boolean;
Default?: string | number | boolean; Default?: string | number | boolean;
Choice?: string[]; Choice?: string[];
Usages?: TemplateType[]; Usages?: TemplateType[];
}; };
export type ModbusCapability = "rs485" | "tcpip"; export type ModbusCapability = "rs485" | "tcpip";
export type ModbusParam = TemplateParam & { export type ModbusParam = TemplateParam & {
ID?: string; ID?: string;
Comset?: string; Comset?: string;
Baudrate?: number; Baudrate?: number;
Port?: number; Port?: number;
}; };
export type DeviceValues = { export type DeviceValues = {
type: ConfigType; type: ConfigType;
icon: string | undefined; icon: string | undefined;
deviceProduct: string | undefined; deviceProduct: string | undefined;
yaml: string | undefined; yaml: string | undefined;
template: string | null; template: string | null;
[key: string]: any; [key: string]: any;
}; };
export function handleError(e: any, msg: string) { export function handleError(e: any, msg: string) {
console.error(e); console.error(e);
let message = msg; let message = msg;
const { error } = e.response.data || {}; const { error } = e.response.data || {};
if (error) message += `: ${error}`; if (error) message += `: ${error}`;
alert(message); alert(message);
} }
export const timeout = 15000; export const timeout = 15000;
export enum ConfigType { export enum ConfigType {
Template = "template", Template = "template",
Custom = "custom", Custom = "custom",
} }
export function applyDefaultsFromTemplate(template: Template | null, values: DeviceValues) { export function applyDefaultsFromTemplate(template: Template | null, values: DeviceValues) {
const params = template?.Params || []; const params = template?.Params || [];
params params
.filter((p) => p.Default && !values[p.Name]) .filter((p) => p.Default && !values[p.Name])
.forEach((p) => { .forEach((p) => {
values[p.Name] = p.Default; values[p.Name] = p.Default;
}); });
} }
export function createDeviceUtils(deviceType: DeviceType) { export function createDeviceUtils(deviceType: DeviceType) {
function test(id: number | undefined, data: any) { function test(id: number | undefined, data: any) {
let url = `config/test/${deviceType}`; let url = `config/test/${deviceType}`;
if (id !== undefined) { if (id !== undefined) {
url += `/merge/${id}`; url += `/merge/${id}`;
} }
return api.post(url, data, { timeout }); return api.post(url, data, { timeout });
} }
function update(id: number, data: any) { function update(id: number, data: any) {
return api.put(`config/devices/${deviceType}/${id}`, data); return api.put(`config/devices/${deviceType}/${id}`, data);
} }
function remove(id: number) { function remove(id: number) {
return api.delete(`config/devices/${deviceType}/${id}`); return api.delete(`config/devices/${deviceType}/${id}`);
} }
async function load(id: number) { async function load(id: number) {
const response = await api.get(`config/devices/${deviceType}/${id}`); const response = await api.get(`config/devices/${deviceType}/${id}`);
return response.data.result; return response.data.result;
} }
async function create(data: any) { async function create(data: any) {
const response = await api.post(`config/devices/${deviceType}`, data); const response = await api.post(`config/devices/${deviceType}`, data);
return response.data.result; return response.data.result;
} }
async function loadProducts(lang?: string, usage?: string) { async function loadProducts(lang?: string, usage?: string) {
const params: Record<string, string | undefined> = { lang }; const params: Record<string, string | undefined> = { lang };
if (usage) { if (usage) {
params["usage"] = usage; params["usage"] = usage;
} }
const response = await api.get(`config/products/${deviceType}`, { params }); const response = await api.get(`config/products/${deviceType}`, { params });
return response.data.result; return response.data.result;
} }
async function loadTemplate(templateName: string, lang?: string) { async function loadTemplate(templateName: string, lang?: string) {
if (!templateName) return null; if (!templateName) return null;
const opts = { const opts = {
params: { params: {
lang, lang,
name: templateName, name: templateName,
}, },
}; };
const response = await api.get(`config/templates/${deviceType}`, opts); const response = await api.get(`config/templates/${deviceType}`, opts);
return response.data.result; return response.data.result;
} }
return { return {
test, test,
update, update,
remove, remove,
load, load,
create, create,
loadProducts, loadProducts,
loadTemplate, loadTemplate,
}; };
} }

View file

@ -1,55 +1,55 @@
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
export type TestState = { export type TestState = {
isUnknown: boolean; isUnknown: boolean;
isSuccess: boolean; isSuccess: boolean;
isError: boolean; isError: boolean;
isRunning: boolean; isRunning: boolean;
result: Record<string, any> | null; result: Record<string, any> | null;
error: string | null; error: string | null;
errorLine: number | null; errorLine: number | null;
}; };
export const initialTestState = (): TestState => ({ export const initialTestState = (): TestState => ({
isUnknown: true, isUnknown: true,
isSuccess: false, isSuccess: false,
isError: false, isError: false,
isRunning: false, isRunning: false,
result: null, result: null,
error: null, error: null,
errorLine: null, errorLine: null,
}); });
export const performTest = async ( export const performTest = async (
state: TestState, state: TestState,
api: () => Promise<AxiosResponse<any, any>>, api: () => Promise<AxiosResponse<any, any>>,
form: HTMLElement | undefined form: HTMLElement | undefined
) => { ) => {
if (form && !(form as HTMLFormElement).reportValidity()) return false; if (form && !(form as HTMLFormElement).reportValidity()) return false;
state.isUnknown = false; state.isUnknown = false;
state.isSuccess = false; state.isSuccess = false;
state.isError = false; state.isError = false;
state.isRunning = true; state.isRunning = true;
state.errorLine = null; state.errorLine = null;
try { try {
const res = await api(); const res = await api();
for (const [key, value] of Object.entries(res.data.result)) { for (const [key, value] of Object.entries(res.data.result)) {
const { error } = value as { error?: string }; const { error } = value as { error?: string };
if (error) { if (error) {
state.isError = true; state.isError = true;
state.error = `${key}: ${error}`; state.error = `${key}: ${error}`;
return false; return false;
} }
} }
state.isSuccess = true; state.isSuccess = true;
state.result = res.data.result; state.result = res.data.result;
return true; return true;
} catch (e: any) { } catch (e: any) {
state.isError = true; state.isError = true;
state.error = e.response?.data?.error || e.message; state.error = e.response?.data?.error || e.message;
state.errorLine = e.response?.data?.line || null; state.errorLine = e.response?.data?.line || null;
} finally { } finally {
state.isRunning = false; state.isRunning = false;
} }
return false; return false;
}; };

View file

@ -2,23 +2,23 @@ import BatteryIcon from "./BatteryIcon.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
export default { export default {
title: "Energyflow/BatteryIcon", title: "Energyflow/BatteryIcon",
component: BatteryIcon, component: BatteryIcon,
parameters: { parameters: {
layout: "centered", layout: "centered",
}, },
} as Meta<typeof BatteryIcon>; } as Meta<typeof BatteryIcon>;
const Template: StoryFn<typeof BatteryIcon> = (args) => { const Template: StoryFn<typeof BatteryIcon> = (args) => {
const story = () => ({ const story = () => ({
components: { BatteryIcon }, components: { BatteryIcon },
setup() { setup() {
return { args }; return { args };
}, },
template: '<BatteryIcon v-bind="args" />', template: '<BatteryIcon v-bind="args" />',
}); });
story.args = args; story.args = args;
return story; return story;
}; };
export const Empty = Template.bind({}); export const Empty = Template.bind({});

View file

@ -3,412 +3,412 @@ import Energyflow from "./Energyflow.vue";
import { CURRENCY } from "@/types/evcc"; import { CURRENCY } from "@/types/evcc";
export default { export default {
title: "Energyflow/Energyflow", title: "Energyflow/Energyflow",
component: Energyflow, component: Energyflow,
} as Meta<typeof Energyflow>; } as Meta<typeof Energyflow>;
const Template: StoryFn<typeof Energyflow> = (args) => ({ const Template: StoryFn<typeof Energyflow> = (args) => ({
components: { Energyflow }, components: { Energyflow },
setup() { setup() {
return { args }; return { args };
}, },
template: '<Energyflow v-bind="args" />', template: '<Energyflow v-bind="args" />',
}); });
export const GridAndPV = Template.bind({}); export const GridAndPV = Template.bind({});
GridAndPV.args = { GridAndPV.args = {
gridConfigured: true, gridConfigured: true,
pvConfigured: true, pvConfigured: true,
pvPower: 7300, pvPower: 7300,
gridPower: -2300, gridPower: -2300,
homePower: 800, homePower: 800,
loadpointsCompact: [ loadpointsCompact: [
{ {
power: 1000, power: 1000,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 1000, chargePower: 1000,
connected: true, connected: true,
index: 0, index: 0,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
{ {
power: 1000, power: 1000,
icon: "bike", icon: "bike",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 1000, chargePower: 1000,
connected: true, connected: true,
index: 1, index: 1,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
{ {
power: 2200, power: 2200,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 2200, chargePower: 2200,
connected: true, connected: true,
index: 2, index: 2,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
], ],
tariffGrid: 0.25, tariffGrid: 0.25,
tariffFeedIn: 0.08, tariffFeedIn: 0.08,
smartCostType: "price", smartCostType: "price",
currency: CURRENCY.EUR, currency: CURRENCY.EUR,
pv: [{ power: 5000 }, { power: 2300 }], pv: [{ power: 5000 }, { power: 2300 }],
forecast: { forecast: {
solar: { solar: {
today: { energy: 1000, complete: true }, today: { energy: 1000, complete: true },
tomorrow: { energy: 1000, complete: false }, tomorrow: { energy: 1000, complete: false },
dayAfterTomorrow: { energy: 1000, complete: false }, dayAfterTomorrow: { energy: 1000, complete: false },
}, },
}, },
}; };
export const BatteryAndGrid = Template.bind({}); export const BatteryAndGrid = Template.bind({});
BatteryAndGrid.args = { BatteryAndGrid.args = {
gridConfigured: true, gridConfigured: true,
pvConfigured: true, pvConfigured: true,
batteryConfigured: true, batteryConfigured: true,
pvPower: 0, pvPower: 0,
gridPower: 1200, gridPower: 1200,
homePower: 2000, homePower: 2000,
batteryPower: 800, batteryPower: 800,
batterySoc: 77, batterySoc: 77,
tariffGrid: 0.25, tariffGrid: 0.25,
tariffFeedIn: 0.08, tariffFeedIn: 0.08,
currency: CURRENCY.EUR, currency: CURRENCY.EUR,
battery: [ battery: [
{ soc: 44.999, capacity: 13.3, power: 350, controllable: true }, { soc: 44.999, capacity: 13.3, power: 350, controllable: true },
{ soc: 82.3331, capacity: 21, power: 450, controllable: false }, { soc: 82.3331, capacity: 21, power: 450, controllable: false },
], ],
}; };
export const BatteryCharging = Template.bind({}); export const BatteryCharging = Template.bind({});
BatteryCharging.args = { BatteryCharging.args = {
gridConfigured: true, gridConfigured: true,
pvConfigured: true, pvConfigured: true,
batteryConfigured: true, batteryConfigured: true,
pvPower: 5000, pvPower: 5000,
gridPower: -1300, gridPower: -1300,
homePower: 800, homePower: 800,
loadpointsCompact: [ loadpointsCompact: [
{ {
power: 1400, power: 1400,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 1400, chargePower: 1400,
connected: true, connected: true,
index: 0, index: 0,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
], ],
batteryPower: -1500, batteryPower: -1500,
batterySoc: 75, batterySoc: 75,
}; };
export const GridPVAndBattery = Template.bind({}); export const GridPVAndBattery = Template.bind({});
GridPVAndBattery.args = { GridPVAndBattery.args = {
gridConfigured: true, gridConfigured: true,
pvConfigured: true, pvConfigured: true,
batteryConfigured: true, batteryConfigured: true,
pvPower: 1000, pvPower: 1000,
gridPower: 700, gridPower: 700,
homePower: 3300, homePower: 3300,
batteryPower: 1500, batteryPower: 1500,
batterySoc: 30, batterySoc: 30,
}; };
export const BatteryThresholds = Template.bind({}); export const BatteryThresholds = Template.bind({});
BatteryThresholds.args = { BatteryThresholds.args = {
gridConfigured: true, gridConfigured: true,
pvConfigured: true, pvConfigured: true,
batteryConfigured: true, batteryConfigured: true,
pvPower: 8700, pvPower: 8700,
gridPower: -500, gridPower: -500,
loadpointsCompact: [ loadpointsCompact: [
{ {
power: 5000, power: 5000,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 5000, chargePower: 5000,
connected: true, connected: true,
index: 0, index: 0,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
{ {
power: 2500, power: 2500,
icon: "bus", icon: "bus",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 2500, chargePower: 2500,
connected: true, connected: true,
index: 1, index: 1,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
], ],
batteryPower: -700, batteryPower: -700,
batterySoc: 95, batterySoc: 95,
}; };
export const PVThresholds = Template.bind({}); export const PVThresholds = Template.bind({});
PVThresholds.args = { PVThresholds.args = {
gridConfigured: true, gridConfigured: true,
pvConfigured: true, pvConfigured: true,
batteryConfigured: true, batteryConfigured: true,
pvPower: 300, pvPower: 300,
gridPower: 6500, gridPower: 6500,
homePower: 1000, homePower: 1000,
loadpointsCompact: [ loadpointsCompact: [
{ {
power: 5000, power: 5000,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 5000, chargePower: 5000,
connected: true, connected: true,
index: 0, index: 0,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
{ {
power: 1600, power: 1600,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 1600, chargePower: 1600,
connected: true, connected: true,
index: 1, index: 1,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
], ],
batteryPower: 800, batteryPower: 800,
batterySoc: 76, batterySoc: 76,
}; };
export const GridOnly = Template.bind({}); export const GridOnly = Template.bind({});
GridOnly.args = { GridOnly.args = {
gridConfigured: true, gridConfigured: true,
pvConfigured: true, pvConfigured: true,
batteryConfigured: true, batteryConfigured: true,
pvPower: 0, pvPower: 0,
gridPower: 6500, gridPower: 6500,
homePower: 1000, homePower: 1000,
loadpointsCompact: [ loadpointsCompact: [
{ {
power: 5500, power: 5500,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 5500, chargePower: 5500,
connected: true, connected: true,
index: 0, index: 0,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
{ {
power: 0, power: 0,
icon: "car", icon: "car",
charging: false, charging: false,
title: "Garage", title: "Garage",
chargePower: 0, chargePower: 0,
connected: false, connected: false,
index: 1, index: 1,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
{ {
power: 0, power: 0,
icon: "car", icon: "car",
charging: false, charging: false,
title: "Garage", title: "Garage",
chargePower: 0, chargePower: 0,
connected: false, connected: false,
index: 2, index: 2,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
{ {
power: 0, power: 0,
icon: "car", icon: "car",
charging: false, charging: false,
title: "Garage", title: "Garage",
chargePower: 0, chargePower: 0,
connected: false, connected: false,
index: 3, index: 3,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
], ],
batteryPower: 0, batteryPower: 0,
batterySoc: 0, batterySoc: 0,
}; };
export const LowPower = Template.bind({}); export const LowPower = Template.bind({});
LowPower.args = { LowPower.args = {
gridConfigured: true, gridConfigured: true,
pvConfigured: true, pvConfigured: true,
batteryConfigured: true, batteryConfigured: true,
pvPower: 700, pvPower: 700,
gridPower: -300, gridPower: -300,
homePower: 300, homePower: 300,
batteryPower: -100, batteryPower: -100,
batterySoc: 55, batterySoc: 55,
tariffGrid: 0.25, tariffGrid: 0.25,
tariffFeedIn: 0.08, tariffFeedIn: 0.08,
currency: CURRENCY.EUR, currency: CURRENCY.EUR,
}; };
export const CO2 = Template.bind({}); export const CO2 = Template.bind({});
CO2.args = { CO2.args = {
gridConfigured: true, gridConfigured: true,
pvConfigured: true, pvConfigured: true,
pvPower: 7300, pvPower: 7300,
gridPower: -2300, gridPower: -2300,
homePower: 800, homePower: 800,
loadpointsCompact: [ loadpointsCompact: [
{ {
power: 1000, power: 1000,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 1000, chargePower: 1000,
connected: true, connected: true,
index: 0, index: 0,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
{ {
power: 1000, power: 1000,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 1000, chargePower: 1000,
connected: true, connected: true,
index: 1, index: 1,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
{ {
power: 2200, power: 2200,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 2200, chargePower: 2200,
connected: true, connected: true,
index: 2, index: 2,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
], ],
tariffGrid: 0.25, tariffGrid: 0.25,
tariffFeedIn: 0.08, tariffFeedIn: 0.08,
tariffCo2: 723, tariffCo2: 723,
smartCostType: "co2", smartCostType: "co2",
currency: CURRENCY.EUR, currency: CURRENCY.EUR,
pv: [{ power: 5000 }, { power: 2300 }], pv: [{ power: 5000 }, { power: 2300 }],
}; };
export const UnknownInput = Template.bind({}); export const UnknownInput = Template.bind({});
UnknownInput.args = { UnknownInput.args = {
gridConfigured: true, gridConfigured: true,
pvConfigured: true, pvConfigured: true,
pvPower: 2000, pvPower: 2000,
gridPower: -2000, gridPower: -2000,
loadpointsCompact: [ loadpointsCompact: [
{ {
power: 1000, power: 1000,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 1000, chargePower: 1000,
connected: true, connected: true,
index: 0, index: 0,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
], ],
}; };
export const UnknownInputFill = Template.bind({}); export const UnknownInputFill = Template.bind({});
UnknownInputFill.args = { UnknownInputFill.args = {
gridConfigured: true, gridConfigured: true,
pvConfigured: true, pvConfigured: true,
batteryConfigured: true, batteryConfigured: true,
pvPower: 500, pvPower: 500,
gridPower: 0, gridPower: 0,
batteryPower: -1000, batteryPower: -1000,
loadpointsCompact: [], loadpointsCompact: [],
}; };
export const UnknownOutput = Template.bind({}); export const UnknownOutput = Template.bind({});
UnknownOutput.args = { UnknownOutput.args = {
gridConfigured: true, gridConfigured: true,
pvConfigured: true, pvConfigured: true,
pvPower: 3000, pvPower: 3000,
gridPower: -1000, gridPower: -1000,
loadpointsCompact: [ loadpointsCompact: [
{ {
power: 1700, power: 1700,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 1700, chargePower: 1700,
connected: true, connected: true,
index: 0, index: 0,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
], ],
}; };
export const UnknownOutputLessThan10Percent = Template.bind({}); export const UnknownOutputLessThan10Percent = Template.bind({});
UnknownOutputLessThan10Percent.args = { UnknownOutputLessThan10Percent.args = {
gridConfigured: true, gridConfigured: true,
pvConfigured: true, pvConfigured: true,
pvPower: 3000, pvPower: 3000,
gridPower: -1000, gridPower: -1000,
loadpointsCompact: [ loadpointsCompact: [
{ {
power: 1800, power: 1800,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 1800, chargePower: 1800,
connected: true, connected: true,
index: 0, index: 0,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
], ],
}; };

View file

@ -1,4 +1,4 @@
interface Pv { interface Pv {
title?: string; title?: string;
power: number; power: number;
} }

View file

@ -2,58 +2,58 @@ import Footer from "./Footer.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
export default { export default {
title: "Footer/Footer", title: "Footer/Footer",
component: Footer, component: Footer,
argTypes: { argTypes: {
version: { control: "object" }, version: { control: "object" },
savings: { control: "object" }, savings: { control: "object" },
}, },
} as Meta<typeof Footer>; } as Meta<typeof Footer>;
const Template: StoryFn<typeof Footer> = (args) => ({ const Template: StoryFn<typeof Footer> = (args) => ({
components: { Footer }, components: { Footer },
setup() { setup() {
return { args }; return { args };
}, },
template: '<Footer v-bind="args" />', template: '<Footer v-bind="args" />',
}); });
export const Default = Template.bind({}); export const Default = Template.bind({});
Default.args = { Default.args = {
version: { version: {
installed: "0.401", installed: "0.401",
available: "0.401", available: "0.401",
}, },
savings: { savings: {
statistics: { statistics: {
"30d": { solarPercentage: 50 }, "30d": { solarPercentage: 50 },
}, },
}, },
}; };
export const UpdateNightly = Template.bind({}); export const UpdateNightly = Template.bind({});
UpdateNightly.args = { UpdateNightly.args = {
version: { version: {
installed: "0.400", installed: "0.400",
available: "0.400", available: "0.400",
commit: "5ce7be4", commit: "5ce7be4",
}, },
savings: { savings: {
statistics: { statistics: {
"30d": { solarPercentage: 22 }, "30d": { solarPercentage: 22 },
}, },
}, },
}; };
export const UpdateAvailable = Template.bind({}); export const UpdateAvailable = Template.bind({});
UpdateAvailable.args = { UpdateAvailable.args = {
version: { version: {
installed: "0.400", installed: "0.400",
available: "0.500", available: "0.500",
}, },
savings: { savings: {
statistics: { statistics: {
"30d": { solarPercentage: 100 }, "30d": { solarPercentage: 100 },
}, },
}, },
}; };

View file

@ -2,32 +2,32 @@ import OfflineIndicator from "./OfflineIndicator.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
export default { export default {
title: "Footer/OfflineIndicator", title: "Footer/OfflineIndicator",
component: OfflineIndicator, component: OfflineIndicator,
argTypes: { argTypes: {
offline: { control: "boolean" }, offline: { control: "boolean" },
fatal: { control: "object" }, fatal: { control: "object" },
}, },
} as Meta<typeof OfflineIndicator>; } as Meta<typeof OfflineIndicator>;
const Template: StoryFn<typeof OfflineIndicator> = (args) => ({ const Template: StoryFn<typeof OfflineIndicator> = (args) => ({
components: { OfflineIndicator }, components: { OfflineIndicator },
setup() { setup() {
return { args }; return { args };
}, },
template: '<OfflineIndicator v-bind="args" />', template: '<OfflineIndicator v-bind="args" />',
}); });
export const Offline = Template.bind({}); export const Offline = Template.bind({});
Offline.args = { Offline.args = {
offline: true, offline: true,
}; };
export const Fatal = Template.bind({}); export const Fatal = Template.bind({});
Fatal.args = { Fatal.args = {
fatal: { fatal: {
error: `cannot create charger 'wallbox_cphl': cannot create charger type 'template': cannot create error: `cannot create charger 'wallbox_cphl': cannot create charger type 'template': cannot create
charger type 'hardybarth-ecbl: Post http://192.168.2.219/api/v1/chargecontrols/1/mode: context deadline exceeded (Client.Timeout charger type 'hardybarth-ecbl: Post http://192.168.2.219/api/v1/chargecontrols/1/mode: context deadline exceeded (Client.Timeout
exceeded while awaiting headers)`, exceeded while awaiting headers)`,
}, },
}; };

View file

@ -2,61 +2,61 @@ import Version from "./Version.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
const releaseNotes = const releaseNotes =
'<h1>0.141</h1><p>Bug fixes:</p><ul><li>Withdraw Kia and Hyundai apis (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="817450535" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/699" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/699/hovercard" href="https://github.com/evcc-io/evcc/pull/699">#699</a>)</li><li>Simplify Tesla integration and fix upstream changes (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="817525274" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/700" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/700/hovercard" href="https://github.com/evcc-io/evcc/pull/700">#700</a>)</li><li>SHM: Check connected status (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="811567860" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/673" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/673/hovercard" href="https://github.com/evcc-io/evcc/pull/673">#673</a>)</li></ul><p>Enhancements:</p><ul><li>Add Seat api (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="812885392" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/681" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/681/hovercard" href="https://github.com/evcc-io/evcc/pull/681">#681</a>)</li><li>Add Skoda api (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="812883796" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/680" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/680/hovercard" href="https://github.com/evcc-io/evcc/pull/680">#680</a>)</li><li>Add Peugeot/Opel/Citroen api (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="815612446" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/692" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/692/hovercard" href="https://github.com/evcc-io/evcc/pull/692">#692</a>)</li><li>KEBA: Support mA current resolution adjustments (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="801358501" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/646" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/646/hovercard" href="https://github.com/evcc-io/evcc/pull/646">#646</a>)</li></ul>'; '<h1>0.141</h1><p>Bug fixes:</p><ul><li>Withdraw Kia and Hyundai apis (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="817450535" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/699" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/699/hovercard" href="https://github.com/evcc-io/evcc/pull/699">#699</a>)</li><li>Simplify Tesla integration and fix upstream changes (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="817525274" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/700" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/700/hovercard" href="https://github.com/evcc-io/evcc/pull/700">#700</a>)</li><li>SHM: Check connected status (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="811567860" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/673" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/673/hovercard" href="https://github.com/evcc-io/evcc/pull/673">#673</a>)</li></ul><p>Enhancements:</p><ul><li>Add Seat api (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="812885392" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/681" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/681/hovercard" href="https://github.com/evcc-io/evcc/pull/681">#681</a>)</li><li>Add Skoda api (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="812883796" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/680" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/680/hovercard" href="https://github.com/evcc-io/evcc/pull/680">#680</a>)</li><li>Add Peugeot/Opel/Citroen api (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="815612446" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/692" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/692/hovercard" href="https://github.com/evcc-io/evcc/pull/692">#692</a>)</li><li>KEBA: Support mA current resolution adjustments (<a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="801358501" data-permission-text="Title is private" data-url="https://github.com/evcc-io/evcc/issues/646" data-hovercard-type="pull_request" data-hovercard-url="/evcc-io/evcc/pull/646/hovercard" href="https://github.com/evcc-io/evcc/pull/646">#646</a>)</li></ul>';
export default { export default {
title: "Footer/Version", title: "Footer/Version",
component: Version, component: Version,
parameters: { parameters: {
layout: "centered", layout: "centered",
}, },
argTypes: { argTypes: {
installed: { control: "text" }, installed: { control: "text" },
available: { control: "text" }, available: { control: "text" },
commit: { control: "text" }, commit: { control: "text" },
releaseNotes: { control: "text" }, releaseNotes: { control: "text" },
hasUpdater: { control: "boolean" }, hasUpdater: { control: "boolean" },
}, },
} as Meta<typeof Version>; } as Meta<typeof Version>;
const Template: StoryFn<typeof Version> = (args) => ({ const Template: StoryFn<typeof Version> = (args) => ({
components: { Version }, components: { Version },
setup() { setup() {
return { args }; return { args };
}, },
template: '<Version v-bind="args" />', template: '<Version v-bind="args" />',
}); });
export const Latest = Template.bind({}); export const Latest = Template.bind({});
Latest.args = { Latest.args = {
installed: "0.141", installed: "0.141",
available: "0.141", available: "0.141",
}; };
export const Nightly = Template.bind({}); export const Nightly = Template.bind({});
Nightly.args = { Nightly.args = {
installed: "0.141", installed: "0.141",
available: "0.141", available: "0.141",
commit: "5ce7be4", commit: "5ce7be4",
}; };
export const UpdateAvailable = Template.bind({}); export const UpdateAvailable = Template.bind({});
UpdateAvailable.args = { UpdateAvailable.args = {
installed: "0.141", installed: "0.141",
available: "0.142", available: "0.142",
releaseNotes, releaseNotes,
}; };
export const AutoUpdater = Template.bind({}); export const AutoUpdater = Template.bind({});
AutoUpdater.args = { AutoUpdater.args = {
installed: "0.141", installed: "0.141",
available: "0.142", available: "0.142",
releaseNotes, releaseNotes,
hasUpdater: true, hasUpdater: true,
}; };
export const NoReleaseNotes = Template.bind({}); export const NoReleaseNotes = Template.bind({});
NoReleaseNotes.args = { NoReleaseNotes.args = {
installed: "0.141", installed: "0.141",
available: "0.142", available: "0.142",
}; };

View file

@ -1,27 +1,27 @@
export function isForecastSlot(obj?: TimeseriesEntry | ForecastSlot): obj is ForecastSlot { export function isForecastSlot(obj?: TimeseriesEntry | ForecastSlot): obj is ForecastSlot {
return (obj as ForecastSlot).start !== undefined; return (obj as ForecastSlot).start !== undefined;
} }
export interface TimeseriesEntry { export interface TimeseriesEntry {
val: number; val: number;
ts: string; ts: string;
} }
export interface ForecastSlot { export interface ForecastSlot {
start: string; start: string;
end: string; end: string;
value: number; value: number;
} }
export interface EnergyByDay { export interface EnergyByDay {
energy: number; energy: number;
complete: boolean; complete: boolean;
} }
export interface SolarDetails { export interface SolarDetails {
scale?: number; scale?: number;
today?: EnergyByDay; today?: EnergyByDay;
tomorrow?: EnergyByDay; tomorrow?: EnergyByDay;
dayAfterTomorrow?: EnergyByDay; dayAfterTomorrow?: EnergyByDay;
timeseries?: TimeseriesEntry[]; timeseries?: TimeseriesEntry[];
} }

View file

@ -2,77 +2,77 @@ import Loadpoint from "./Loadpoint.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
export default { export default {
title: "Loadpoints/Loadpoint", title: "Loadpoints/Loadpoint",
component: Loadpoint, component: Loadpoint,
parameters: { parameters: {
layout: "centered", layout: "centered",
}, },
} as Meta<typeof Loadpoint>; } as Meta<typeof Loadpoint>;
const baseState = { const baseState = {
id: 0, id: 0,
pvConfigured: true, pvConfigured: true,
chargePower: 2800, chargePower: 2800,
chargedEnergy: 11e3, chargedEnergy: 11e3,
chargeDuration: 95 * 60, chargeDuration: 95 * 60,
vehicleTitle: "Mein Auto", vehicleTitle: "Mein Auto",
vehicleName: "meinauto", vehicleName: "meinauto",
enabled: true, enabled: true,
connected: true, connected: true,
mode: "pv", mode: "pv",
charging: true, charging: true,
vehicleSoc: 66, vehicleSoc: 66,
limitSoc: 90, limitSoc: 90,
offeredCurrent: 7, offeredCurrent: 7,
minCurrent: 6, minCurrent: 6,
maxCurrent: 16, maxCurrent: 16,
activePhases: 2, activePhases: 2,
}; };
const Template: StoryFn<typeof Loadpoint> = (args) => { const Template: StoryFn<typeof Loadpoint> = (args) => {
const story = () => ({ const story = () => ({
components: { Loadpoint }, components: { Loadpoint },
setup() { setup() {
return { args }; return { args };
}, },
template: '<Loadpoint v-bind="args" />', template: '<Loadpoint v-bind="args" />',
}); });
story.args = { ...baseState, ...args }; story.args = { ...baseState, ...args };
return story; return story;
}; };
export const Standard = Template.bind({}); export const Standard = Template.bind({});
export const WithoutSoc = Template.bind({}); export const WithoutSoc = Template.bind({});
WithoutSoc.args = { WithoutSoc.args = {
vehicleName: "", vehicleName: "",
}; };
export const Idle = Template.bind({}); export const Idle = Template.bind({});
Idle.args = { Idle.args = {
enabled: false, enabled: false,
connected: false, connected: false,
vehicleName: "", vehicleName: "",
mode: "off", mode: "off",
charging: false, charging: false,
offeredCurrent: 0, offeredCurrent: 0,
}; };
export const DisabledLongTitle = Template.bind({}); export const DisabledLongTitle = Template.bind({});
DisabledLongTitle.args = { DisabledLongTitle.args = {
title: "Charging point with a very very very long title!!!1!", title: "Charging point with a very very very long title!!!1!",
remoteDisabled: "soft", remoteDisabled: "soft",
remoteDisabledSource: "Sunny Home Manager", remoteDisabledSource: "Sunny Home Manager",
mode: "now", mode: "now",
enabled: false, enabled: false,
charging: false, charging: false,
chargePower: 0, chargePower: 0,
}; };
export const ChargerIconNoVehicle = Template.bind({}); export const ChargerIconNoVehicle = Template.bind({});
ChargerIconNoVehicle.args = { ChargerIconNoVehicle.args = {
chargerIcon: "heater", chargerIcon: "heater",
title: "Heating device with long name", title: "Heating device with long name",
mode: "now", mode: "now",
chargerFeatureIntegratedDevice: true, chargerFeatureIntegratedDevice: true,
}; };

View file

@ -2,59 +2,59 @@ import Loadpoints from "./Loadpoints.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
function loadpoint(opts = {}) { function loadpoint(opts = {}) {
const base = { const base = {
id: 0, id: 0,
pvConfigured: true, pvConfigured: true,
chargePower: 2800, chargePower: 2800,
chargedEnergy: 11e3, chargedEnergy: 11e3,
chargeDuration: 95 * 60, chargeDuration: 95 * 60,
vehicleName: "tesla", vehicleName: "tesla",
vehicleTitle: "Tesla Model 3", vehicleTitle: "Tesla Model 3",
enabled: true, enabled: true,
connected: true, connected: true,
mode: "pv", mode: "pv",
charging: true, charging: true,
vehicleSoc: 66, vehicleSoc: 66,
limitSoc: 90, limitSoc: 90,
offeredCurrent: 7, offeredCurrent: 7,
minCurrent: 6, minCurrent: 6,
maxCurrent: 16, maxCurrent: 16,
activePhases: 2, activePhases: 2,
icon: "car", icon: "car",
title: "Garage", title: "Garage",
power: 2100, power: 2100,
index: 0, index: 0,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}; };
return { ...base, ...opts }; return { ...base, ...opts };
} }
export default { export default {
title: "Loadpoints/Loadpoints", title: "Loadpoints/Loadpoints",
component: Loadpoints, component: Loadpoints,
parameters: { parameters: {
layout: "centered", layout: "centered",
}, },
} as Meta<typeof Loadpoints>; } as Meta<typeof Loadpoints>;
const Template: StoryFn<typeof Loadpoints> = (args) => ({ const Template: StoryFn<typeof Loadpoints> = (args) => ({
components: { Loadpoints }, components: { Loadpoints },
setup() { setup() {
return { args }; return { args };
}, },
template: '<Loadpoints v-bind="args" />', template: '<Loadpoints v-bind="args" />',
}); });
export const Standard = Template.bind({}); export const Standard = Template.bind({});
Standard.args = { Standard.args = {
vehicles: [], vehicles: [],
loadpoints: [ loadpoints: [
loadpoint({ title: "Carport", index: 0 }), loadpoint({ title: "Carport", index: 0 }),
loadpoint({ loadpoint({
title: "Water Heater", title: "Water Heater",
chargerFeatureIntegratedDevice: true, chargerFeatureIntegratedDevice: true,
chargerIcon: "waterheater", chargerIcon: "waterheater",
index: 1, index: 1,
}), }),
], ],
}; };

View file

@ -2,32 +2,32 @@ import Mode from "./Mode.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
export default { export default {
title: "Loadpoints/Mode", title: "Loadpoints/Mode",
component: Mode, component: Mode,
argTypes: { argTypes: {
mode: { mode: {
control: "select", control: "select",
options: ["off", "now", "minpv", "pv"], options: ["off", "now", "minpv", "pv"],
description: "Charging mode", description: "Charging mode",
}, },
pvPossible: { control: "boolean", description: "Whether PV is possible" }, pvPossible: { control: "boolean", description: "Whether PV is possible" },
hasSmartCost: { control: "boolean", description: "Whether smart cost is available" }, hasSmartCost: { control: "boolean", description: "Whether smart cost is available" },
}, },
parameters: { parameters: {
layout: "centered", layout: "centered",
}, },
} as Meta<typeof Mode>; } as Meta<typeof Mode>;
const Template: StoryFn<typeof Mode> = (args) => { const Template: StoryFn<typeof Mode> = (args) => {
const story = () => ({ const story = () => ({
components: { Mode }, components: { Mode },
setup() { setup() {
return { args }; return { args };
}, },
template: '<Mode v-bind="args" />', template: '<Mode v-bind="args" />',
}); });
story.args = args; story.args = args;
return story; return story;
}; };
export const Minimal = Template.bind({}); export const Minimal = Template.bind({});
@ -35,14 +35,14 @@ Minimal.args = { mode: "now" };
export const Full = Template.bind({}); export const Full = Template.bind({});
Full.args = { Full.args = {
mode: "pv", mode: "pv",
pvPossible: true, pvPossible: true,
hasSmartCost: true, hasSmartCost: true,
}; };
export const SmartGridOnly = Template.bind({}); export const SmartGridOnly = Template.bind({});
SmartGridOnly.args = { SmartGridOnly.args = {
mode: "pv", mode: "pv",
pvPossible: false, pvPossible: false,
hasSmartCost: true, hasSmartCost: true,
}; };

View file

@ -2,94 +2,94 @@ import Phases from "./Phases.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
export default { export default {
title: "Loadpoints/Phases", title: "Loadpoints/Phases",
component: Phases, component: Phases,
parameters: { parameters: {
layout: "centered", layout: "centered",
}, },
argTypes: { argTypes: {
phasesActive: { control: { type: "number" } }, phasesActive: { control: { type: "number" } },
minCurrent: { control: { type: "number" } }, minCurrent: { control: { type: "number" } },
maxCurrent: { control: { type: "number" } }, maxCurrent: { control: { type: "number" } },
offeredCurrent: { control: { type: "number" } }, offeredCurrent: { control: { type: "number" } },
chargeCurrents: { control: { type: "object" } }, chargeCurrents: { control: { type: "object" } },
}, },
} as Meta<typeof Phases>; } as Meta<typeof Phases>;
const Template: StoryFn<typeof Phases> = (args) => ({ const Template: StoryFn<typeof Phases> = (args) => ({
components: { Phases }, components: { Phases },
setup() { setup() {
return { args }; return { args };
}, },
template: '<Phases v-bind="args" />', template: '<Phases v-bind="args" />',
}); });
export const OnePhase = Template.bind({}); export const OnePhase = Template.bind({});
OnePhase.args = { OnePhase.args = {
phasesActive: 1, phasesActive: 1,
minCurrent: 6, minCurrent: 6,
maxCurrent: 16, maxCurrent: 16,
offeredCurrent: 8, offeredCurrent: 8,
chargeCurrents: undefined, chargeCurrents: undefined,
}; };
export const TwoPhase = Template.bind({}); export const TwoPhase = Template.bind({});
TwoPhase.args = { TwoPhase.args = {
...OnePhase.args, ...OnePhase.args,
phasesActive: 2, phasesActive: 2,
}; };
export const ThreePhase = Template.bind({}); export const ThreePhase = Template.bind({});
ThreePhase.args = { ThreePhase.args = {
...OnePhase.args, ...OnePhase.args,
phasesActive: 3, phasesActive: 3,
}; };
export const RealCurrents = Template.bind({}); export const RealCurrents = Template.bind({});
RealCurrents.args = { RealCurrents.args = {
...OnePhase.args, ...OnePhase.args,
phasesActive: 3, phasesActive: 3,
offeredCurrent: 13, offeredCurrent: 13,
chargeCurrents: [11, 9, 12], chargeCurrents: [11, 9, 12],
}; };
export const OnePhaseMoreAvailable = Template.bind({}); export const OnePhaseMoreAvailable = Template.bind({});
OnePhaseMoreAvailable.args = { OnePhaseMoreAvailable.args = {
...OnePhase.args, ...OnePhase.args,
offeredCurrent: 12, offeredCurrent: 12,
chargeCurrents: [6, 0.2, 0], chargeCurrents: [6, 0.2, 0],
}; };
export const TwoPhasesActive = Template.bind({}); export const TwoPhasesActive = Template.bind({});
TwoPhasesActive.args = { TwoPhasesActive.args = {
...OnePhase.args, ...OnePhase.args,
phasesActive: 2, phasesActive: 2,
offeredCurrent: 16, offeredCurrent: 16,
chargeCurrents: [16, 16, 0.3], chargeCurrents: [16, 16, 0.3],
}; };
export const AsymetricPhases = Template.bind({}); export const AsymetricPhases = Template.bind({});
AsymetricPhases.args = { AsymetricPhases.args = {
...OnePhase.args, ...OnePhase.args,
phasesActive: 2, phasesActive: 2,
offeredCurrent: 16, offeredCurrent: 16,
chargeCurrents: [8, 0.9, 14], chargeCurrents: [8, 0.9, 14],
}; };
export const OnlySecondPhase = Template.bind({}); export const OnlySecondPhase = Template.bind({});
OnlySecondPhase.args = { OnlySecondPhase.args = {
...OnePhase.args, ...OnePhase.args,
phasesActive: 1, phasesActive: 1,
offeredCurrent: 13, offeredCurrent: 13,
chargeCurrents: [0, 13, 0], chargeCurrents: [0, 13, 0],
}; };
export const MainlyThirdPhase = Template.bind({}); export const MainlyThirdPhase = Template.bind({});
MainlyThirdPhase.args = { MainlyThirdPhase.args = {
...OnePhase.args, ...OnePhase.args,
phasesActive: 1, phasesActive: 1,
offeredCurrent: 10, offeredCurrent: 10,
chargeCurrents: [0.007, 0.009, 5.945], chargeCurrents: [0.007, 0.009, 5.945],
minCurrent: 6, minCurrent: 6,
maxCurrent: 20, maxCurrent: 20,
}; };

View file

@ -1,12 +1,12 @@
import settings from "@/settings"; import settings from "@/settings";
export function getSessionInfo(index: number, fallback?: string) { export function getSessionInfo(index: number, fallback?: string) {
return settings.sessionInfo[index - 1] || fallback; return settings.sessionInfo[index - 1] || fallback;
} }
export function setSessionInfo(index: number, value: string) { export function setSessionInfo(index: number, value: string) {
const clone = [...settings.sessionInfo]; const clone = [...settings.sessionInfo];
clone[index - 1] = value; clone[index - 1] = value;
clone.map((v) => v || ""); clone.map((v) => v || "");
settings.sessionInfo = clone; settings.sessionInfo = clone;
} }

View file

@ -3,36 +3,36 @@ import MultiIcon from "./MultiIcon.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
export default { export default {
title: "MultiIcon/MultiIcon", title: "MultiIcon/MultiIcon",
component: MultiIcon, component: MultiIcon,
argTypes: { argTypes: {
count: { count: {
control: { type: "number", min: 1, max: 10 }, control: { type: "number", min: 1, max: 10 },
description: "Number of icons to display", description: "Number of icons to display",
}, },
size: { size: {
options: Object.values(ICON_SIZE), options: Object.values(ICON_SIZE),
control: { type: "select" }, control: { type: "select" },
description: "Size of the icons", description: "Size of the icons",
}, },
}, },
} as Meta<typeof MultiIcon>; } as Meta<typeof MultiIcon>;
export const SingleIcon: StoryFn<typeof MultiIcon> = (args) => ({ export const SingleIcon: StoryFn<typeof MultiIcon> = (args) => ({
components: { MultiIcon }, components: { MultiIcon },
setup() { setup() {
return { args }; return { args };
}, },
template: '<MultiIcon v-bind="args" />', template: '<MultiIcon v-bind="args" />',
}); });
export const AllCounts: StoryFn<typeof MultiIcon> = (args) => ({ export const AllCounts: StoryFn<typeof MultiIcon> = (args) => ({
components: { MultiIcon }, components: { MultiIcon },
setup() { setup() {
const counts = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; const counts = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
return { counts, args }; return { counts, args };
}, },
template: ` template: `
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 30px;"> <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 30px;">
<div v-for="count in counts" :key="count" style="display: flex; flex-direction: column; align-items: center; gap: 10px;"> <div v-for="count in counts" :key="count" style="display: flex; flex-direction: column; align-items: center; gap: 10px;">
<MultiIcon :count="count" :size="args.size" /> <MultiIcon :count="count" :size="args.size" />
@ -43,5 +43,5 @@ export const AllCounts: StoryFn<typeof MultiIcon> = (args) => ({
}); });
AllCounts.args = { AllCounts.args = {
size: ICON_SIZE.XL, size: ICON_SIZE.XL,
}; };

View file

@ -2,16 +2,16 @@ import LiveCommunity from "./LiveCommunity.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
export default { export default {
title: "Savings/LiveCommunity", title: "Savings/LiveCommunity",
component: LiveCommunity, component: LiveCommunity,
parameters: { parameters: {
layout: "centered", layout: "centered",
}, },
} as Meta<typeof LiveCommunity>; } as Meta<typeof LiveCommunity>;
const Template: StoryFn<typeof LiveCommunity> = () => ({ const Template: StoryFn<typeof LiveCommunity> = () => ({
components: { LiveCommunity }, components: { LiveCommunity },
template: "<LiveCommunity />", template: "<LiveCommunity />",
}); });
export const Default = Template.bind({}); export const Default = Template.bind({});

View file

@ -2,34 +2,34 @@ import Sponsor from "./Sponsor.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
export default { export default {
title: "Savings/Sponsor", title: "Savings/Sponsor",
component: Sponsor, component: Sponsor,
parameters: { parameters: {
layout: "centered", layout: "centered",
}, },
argTypes: { argTypes: {
name: { name: {
control: "text", control: "text",
description: "Sponsor name (use 'trial' or 'victron' for special states)", description: "Sponsor name (use 'trial' or 'victron' for special states)",
}, },
expiresAt: { expiresAt: {
control: "text", control: "text",
description: "When the sponsorship expires (ISO date string)", description: "When the sponsorship expires (ISO date string)",
}, },
expiresSoon: { expiresSoon: {
control: "boolean", control: "boolean",
description: "Whether the sponsorship is expiring soon", description: "Whether the sponsorship is expiring soon",
}, },
}, },
} as Meta<typeof Sponsor>; } as Meta<typeof Sponsor>;
// Template for rendering the component // Template for rendering the component
const Template: StoryFn<typeof Sponsor> = (args) => ({ const Template: StoryFn<typeof Sponsor> = (args) => ({
components: { Sponsor }, components: { Sponsor },
setup() { setup() {
return { args }; return { args };
}, },
template: '<Sponsor v-bind="args" />', template: '<Sponsor v-bind="args" />',
}); });
// Create stories for each variant // Create stories for each variant
@ -38,23 +38,23 @@ NoSponsor.args = {};
export const Trial = Template.bind({}); export const Trial = Template.bind({});
Trial.args = { Trial.args = {
name: "trial", name: "trial",
}; };
export const IndividualSponsor = Template.bind({}); export const IndividualSponsor = Template.bind({});
IndividualSponsor.args = { IndividualSponsor.args = {
name: "naltatis", name: "naltatis",
}; };
export const VictronDevice = Template.bind({}); export const VictronDevice = Template.bind({});
VictronDevice.args = { VictronDevice.args = {
name: "victron", name: "victron",
}; };
// Add an extra story showing the expiring state // Add an extra story showing the expiring state
export const ExpiringSponsor = Template.bind({}); export const ExpiringSponsor = Template.bind({});
ExpiringSponsor.args = { ExpiringSponsor.args = {
name: "naltatis", name: "naltatis",
expiresSoon: true, expiresSoon: true,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days from now expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days from now
}; };

View file

@ -2,32 +2,32 @@ import SponsorTokenExpires from "./SponsorTokenExpires.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
export default { export default {
title: "Savings/SponsorTokenExpires", title: "Savings/SponsorTokenExpires",
component: SponsorTokenExpires, component: SponsorTokenExpires,
parameters: { parameters: {
layout: "centered", layout: "centered",
}, },
argTypes: { argTypes: {
expiresSoon: { control: "boolean", description: "Whether sponsorship is expiring soon" }, expiresSoon: { control: "boolean", description: "Whether sponsorship is expiring soon" },
expiresAt: { control: "text", description: "Date when the sponsor token expires" }, expiresAt: { control: "text", description: "Date when the sponsor token expires" },
name: { control: "text", description: "Name of the sponsor token" }, name: { control: "text", description: "Name of the sponsor token" },
}, },
} as Meta<typeof SponsorTokenExpires>; } as Meta<typeof SponsorTokenExpires>;
// Template for rendering the component // Template for rendering the component
const Template: StoryFn<typeof SponsorTokenExpires> = (args) => ({ const Template: StoryFn<typeof SponsorTokenExpires> = (args) => ({
components: { SponsorTokenExpires }, components: { SponsorTokenExpires },
setup() { setup() {
return { args }; return { args };
}, },
template: '<SponsorTokenExpires v-bind="args" />', template: '<SponsorTokenExpires v-bind="args" />',
}); });
// Create stories for each variant // Create stories for each variant
export const SomeDay = Template.bind({}); export const SomeDay = Template.bind({});
SomeDay.args = { SomeDay.args = {
expiresSoon: true, expiresSoon: true,
expiresAt: new Date(Date.now() + 22 * 20 * 60 * 1000).toISOString(), expiresAt: new Date(Date.now() + 22 * 20 * 60 * 1000).toISOString(),
}; };
export const Empty = Template.bind({}); export const Empty = Template.bind({});

View file

@ -2,37 +2,37 @@ import Tile from "./Tile.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
export default { export default {
title: "Savings/Tile", title: "Savings/Tile",
component: Tile, component: Tile,
parameters: { parameters: {
layout: "centered", layout: "centered",
}, },
argTypes: { argTypes: {
icon: { control: "text", description: "Icon name to display" }, icon: { control: "text", description: "Icon name to display" },
title: { control: "text", description: "Title of the tile" }, title: { control: "text", description: "Title of the tile" },
value: { control: "text", description: "Primary value to display" }, value: { control: "text", description: "Primary value to display" },
unit: { control: "text", description: "Unit for the value" }, unit: { control: "text", description: "Unit for the value" },
sub1: { control: "text", description: "First subtitle" }, sub1: { control: "text", description: "First subtitle" },
sub2: { control: "text", description: "Second subtitle" }, sub2: { control: "text", description: "Second subtitle" },
}, },
} as Meta<typeof Tile>; } as Meta<typeof Tile>;
// Template for rendering the component // Template for rendering the component
const Template: StoryFn<typeof Tile> = (args) => ({ const Template: StoryFn<typeof Tile> = (args) => ({
components: { Tile }, components: { Tile },
setup() { setup() {
return { args }; return { args };
}, },
template: '<Tile v-bind="args" />', template: '<Tile v-bind="args" />',
}); });
// Create default story with the example props // Create default story with the example props
export const Default = Template.bind({}); export const Default = Template.bind({});
Default.args = { Default.args = {
icon: "coinjar", icon: "coinjar",
title: "Ersparnis", title: "Ersparnis",
value: "14,2", value: "14,2",
unit: "€", unit: "€",
sub1: "gegenüber Netzbezug", sub1: "gegenüber Netzbezug",
sub2: "seit Dezember 2022", sub2: "seit Dezember 2022",
}; };

View file

@ -3,24 +3,24 @@ const source = "https://ourworldindata.org/grapher/carbon-intensity-electricity?
// This is a manual selection of countries. If yours is missing, please add it with data from the source above. // This is a manual selection of countries. If yours is missing, please add it with data from the source above.
const regions = [ const regions = [
{ name: "Australia", co2: 503 }, { name: "Australia", co2: 503 },
{ name: "Austria", co2: 158 }, { name: "Austria", co2: 158 },
{ name: "Canada", co2: 128 }, { name: "Canada", co2: 128 },
{ name: "Czech Republic", co2: 415 }, { name: "Czech Republic", co2: 415 },
{ name: "Denmark", co2: 181 }, { name: "Denmark", co2: 181 },
{ name: "Estonia", co2: 464 }, { name: "Estonia", co2: 464 },
{ name: "Europe", co2: 278 }, { name: "Europe", co2: 278 },
{ name: "Finland", co2: 131 }, { name: "Finland", co2: 131 },
{ name: "France", co2: 85 }, { name: "France", co2: 85 },
{ name: "Germany", co2: 385 }, { name: "Germany", co2: 385 },
{ name: "Netherlands", co2: 356 }, { name: "Netherlands", co2: 356 },
{ name: "Norway", co2: 29 }, { name: "Norway", co2: 29 },
{ name: "Poland", co2: 635 }, { name: "Poland", co2: 635 },
{ name: "Sweden", co2: 45 }, { name: "Sweden", co2: 45 },
{ name: "Switzerland", co2: 46 }, { name: "Switzerland", co2: 46 },
{ name: "United Kingdom", co2: 257 }, { name: "United Kingdom", co2: 257 },
{ name: "United States", co2: 367 }, { name: "United States", co2: 367 },
{ name: "World", co2: 436 }, { name: "World", co2: 436 },
]; ];
export default { regions, source }; export default { regions, source };

View file

@ -1,21 +1,21 @@
import axios from "axios"; import axios from "axios";
const api = axios.create({ const api = axios.create({
baseURL: "https://api.evcc.io/v1/", baseURL: "https://api.evcc.io/v1/",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
}, },
}); });
// global error handling // global error handling
api.interceptors.response.use( api.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
const url = error.config.baseURL + error.config.url; const url = error.config.baseURL + error.config.url;
const message = `${error.message}: API request failed ${url}`; const message = `${error.message}: API request failed ${url}`;
window.app.raise({ message }); window.app.raise({ message });
return Promise.reject(error); return Promise.reject(error);
} }
); );
export default api; export default api;

View file

@ -1,13 +1,13 @@
export interface LiveCommunityData { export interface LiveCommunityData {
totalClients: number; totalClients: number;
activeClients: number; activeClients: number;
totalInstances: number; totalInstances: number;
activeInstances: number; activeInstances: number;
chargePower: number; chargePower: number;
greenPower: number; greenPower: number;
maxChargePower: number; maxChargePower: number;
maxGreenPower: number; maxGreenPower: number;
chargeEnergy: number; chargeEnergy: number;
greenEnergy: number; greenEnergy: number;
} }
export type Period = "30d" | "365d" | "thisYear" | "total"; export type Period = "30d" | "365d" | "thisYear" | "total";

View file

@ -1,76 +1,76 @@
import { import {
Chart, Chart,
Tooltip, Tooltip,
type ChartComponentLike, type ChartComponentLike,
type ChartType, type ChartType,
type Point, type Point,
type TooltipItem, type TooltipItem,
} from "chart.js"; } from "chart.js";
import colors from "@/colors"; import colors from "@/colors";
import type { Context } from "chartjs-plugin-datalabels"; import type { Context } from "chartjs-plugin-datalabels";
// Register common components // Register common components
export function registerChartComponents(components: ChartComponentLike[]) { export function registerChartComponents(components: ChartComponentLike[]) {
Chart.register(...components); Chart.register(...components);
} }
// Set default configurations immediately // Set default configurations immediately
Chart.defaults.font.family = window Chart.defaults.font.family = window
.getComputedStyle(document.documentElement) .getComputedStyle(document.documentElement)
.getPropertyValue("--bs-font-sans-serif"); .getPropertyValue("--bs-font-sans-serif");
Chart.defaults.font.size = 14; Chart.defaults.font.size = 14;
Chart.defaults.layout.padding = 0; Chart.defaults.layout.padding = 0;
// Custom tooltip positioners // Custom tooltip positioners
(Tooltip.positioners as any)["center"] = function () { (Tooltip.positioners as any)["center"] = function () {
const { chart } = this; const { chart } = this;
return { return {
x: chart.width / 2, x: chart.width / 2,
y: chart.height / 2, y: chart.height / 2,
xAlign: "center", xAlign: "center",
yAlign: "center", yAlign: "center",
}; };
}; };
(Tooltip.positioners as any)["topBottomCenter"] = function ( (Tooltip.positioners as any)["topBottomCenter"] = function (
_context: Context, _context: Context,
eventPosition: Point eventPosition: Point
) { ) {
const { chart } = this; const { chart } = this;
const { height, width } = chart; const { height, width } = chart;
const isTop = eventPosition.y > height / 2; const isTop = eventPosition.y > height / 2;
const yPadding = height / 5; const yPadding = height / 5;
const y = isTop ? yPadding : height - yPadding; const y = isTop ? yPadding : height - yPadding;
const x = width / 2; const x = width / 2;
return { x, y, xAlign: "center", yAlign: "center" }; return { x, y, xAlign: "center", yAlign: "center" };
}; };
export const commonOptions = { export const commonOptions = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
datalabels: { display: false }, datalabels: { display: false },
tooltip: { tooltip: {
backgroundColor: "#000000cc", backgroundColor: "#000000cc",
boxPadding: 5, boxPadding: 5,
usePointStyle: false, usePointStyle: false,
borderWidth: 0.00001, borderWidth: 0.00001,
mode: "index", mode: "index",
intersect: false, intersect: false,
}, },
}, },
}; };
export function tooltipLabelColor(useBorder = false) { export function tooltipLabelColor(useBorder = false) {
return function (item: TooltipItem<ChartType>) { return function (item: TooltipItem<ChartType>) {
const { backgroundColor, borderColor } = item.element.options; const { backgroundColor, borderColor } = item.element.options;
const color = useBorder ? borderColor : backgroundColor; const color = useBorder ? borderColor : backgroundColor;
return { return {
borderColor: !item.raw ? colors.muted : "#fff", borderColor: !item.raw ? colors.muted : "#fff",
borderWidth: 1, borderWidth: 1,
backgroundColor: color, backgroundColor: color,
}; };
}; };
} }

View file

@ -1,49 +1,49 @@
export interface Session { export interface Session {
id: number; id: number;
created: string; created: string;
finished: string; finished: string;
loadpoint: string; loadpoint: string;
identifier: string; identifier: string;
vehicle: string; vehicle: string;
odometer: number; odometer: number;
meterStart: number; meterStart: number;
meterStop: number; meterStop: number;
chargedEnergy: number; chargedEnergy: number;
chargeDuration: number; chargeDuration: number;
solarPercentage: number; solarPercentage: number;
price: number | null; price: number | null;
pricePerKWh: number | null; pricePerKWh: number | null;
co2PerKWh?: number | null; co2PerKWh?: number | null;
} }
export interface Legend { export interface Legend {
label: string; label: string;
color: any; color: any;
value: string | string[]; value: string | string[];
} }
export interface Column { export interface Column {
name: string; name: string;
unit: string; unit: string;
total: number | null; total: number | null;
value: (session: Session) => number | null; value: (session: Session) => number | null;
format: (value: number) => string | undefined; format: (value: number) => string | undefined;
} }
export enum TYPES { export enum TYPES {
SOLAR = "solar", SOLAR = "solar",
PRICE = "price", PRICE = "price",
CO2 = "co2", CO2 = "co2",
} }
export enum GROUPS { export enum GROUPS {
NONE = "none", NONE = "none",
LOADPOINT = "loadpoint", LOADPOINT = "loadpoint",
VEHICLE = "vehicle", VEHICLE = "vehicle",
} }
export enum PERIODS { export enum PERIODS {
MONTH = "month", MONTH = "month",
YEAR = "year", YEAR = "year",
TOTAL = "total", TOTAL = "total",
} }

View file

@ -1,3 +1,3 @@
export interface Grid { export interface Grid {
power?: number; power?: number;
} }

View file

@ -2,23 +2,23 @@ import Navigation from "./Navigation.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
export default { export default {
title: "Top/Navigation", title: "Top/Navigation",
component: Navigation, component: Navigation,
parameters: { parameters: {
layout: "centered", layout: "centered",
}, },
argTypes: { argTypes: {
vehicleLogins: { control: "object" }, vehicleLogins: { control: "object" },
sponsor: { control: "object" }, sponsor: { control: "object" },
}, },
} as Meta<typeof Navigation>; } as Meta<typeof Navigation>;
const Template: StoryFn<typeof Navigation> = (args) => ({ const Template: StoryFn<typeof Navigation> = (args) => ({
components: { Navigation }, components: { Navigation },
setup() { setup() {
return { args }; return { args };
}, },
template: '<Navigation v-bind="args" />', template: '<Navigation v-bind="args" />',
}); });
export const Standard = Template.bind({}); export const Standard = Template.bind({});
@ -26,37 +26,37 @@ Standard.args = {};
export const VehicleLogins = Template.bind({}); export const VehicleLogins = Template.bind({});
VehicleLogins.args = { VehicleLogins.args = {
vehicleLogins: { vehicleLogins: {
"Mercedes EQS": { "Mercedes EQS": {
authenticated: true, authenticated: true,
uri: "https://login-provider-a.test/", uri: "https://login-provider-a.test/",
}, },
"Nissan Leaf Pro": { "Nissan Leaf Pro": {
authenticated: true, authenticated: true,
uri: "https://login-provider-b.test/", uri: "https://login-provider-b.test/",
}, },
}, },
}; };
export const PendingVehicleLogins = Template.bind({}); export const PendingVehicleLogins = Template.bind({});
PendingVehicleLogins.args = { PendingVehicleLogins.args = {
vehicleLogins: { vehicleLogins: {
"Mercedes EQS": { "Mercedes EQS": {
authenticated: true, authenticated: true,
uri: "https://login-provider-a.test/", uri: "https://login-provider-a.test/",
}, },
"Nissan Leaf Pro": { "Nissan Leaf Pro": {
authenticated: false, authenticated: false,
uri: "https://login-provider-b.test/", uri: "https://login-provider-b.test/",
}, },
}, },
}; };
export const TokenExpires = Template.bind({}); export const TokenExpires = Template.bind({});
TokenExpires.args = { TokenExpires.args = {
sponsor: { sponsor: {
name: "Sponsor", name: "Sponsor",
expiresAt: new Date().toISOString(), expiresAt: new Date().toISOString(),
expiresSoon: true, expiresSoon: true,
}, },
}; };

View file

@ -3,88 +3,87 @@ import Notifications from "./Notifications.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
const timeAgo = (hours = 0, minutes = 0, seconds = 0) => { const timeAgo = (hours = 0, minutes = 0, seconds = 0) => {
const date = new Date(); const date = new Date();
date.setHours(date.getHours() - hours); date.setHours(date.getHours() - hours);
date.setMinutes(date.getMinutes() - minutes); date.setMinutes(date.getMinutes() - minutes);
date.setSeconds(date.getSeconds() - seconds); date.setSeconds(date.getSeconds() - seconds);
return date; return date;
}; };
const notificationsData: Notification[] = [ const notificationsData: Notification[] = [
{ {
message: "Server unavailable", message: "Server unavailable",
level: "error", level: "error",
time: timeAgo(), time: timeAgo(),
count: 1, count: 1,
lp: 1, lp: 1,
}, },
{ {
message: "charger out of sync: expected disabled, got enabled", message: "charger out of sync: expected disabled, got enabled",
level: "warn", level: "warn",
count: 4, count: 4,
time: timeAgo(0, 0, 42), time: timeAgo(0, 0, 42),
lp: 1, lp: 1,
}, },
{ {
message: "Sponsortoken: x509: certificate has expired", message: "Sponsortoken: x509: certificate has expired",
level: "error", level: "error",
count: 1, count: 1,
time: timeAgo(1, 12, 44), time: timeAgo(1, 12, 44),
lp: 1, lp: 1,
}, },
{ {
message: message:
"Block device full: can not write to /tmp/evcc/foobarloremawefhwuiehfwuiehfwiauhefjkajowaeigjwalvmoweivwail", "Block device full: can not write to /tmp/evcc/foobarloremawefhwuiehfwuiehfwiauhefjkajowaeigjwalvmoweivwail",
level: "error", level: "error",
count: 1, count: 1,
time: timeAgo(1, 12, 44), time: timeAgo(1, 12, 44),
lp: 1, lp: 1,
}, },
{ {
message: "charger out of sync: expected disabled, got enabled", message: "charger out of sync: expected disabled, got enabled",
level: "warn", level: "warn",
count: 4, count: 4,
time: timeAgo(1, 22, 0), time: timeAgo(1, 22, 0),
lp: 1, lp: 1,
}, },
{ {
message: message: "vehicle remote charge start: invalid character '<' looking for beginning of value",
"vehicle remote charge start: invalid character '<' looking for beginning of value", level: "warn",
level: "warn", count: 3,
count: 3, time: timeAgo(4, 2, 0),
time: timeAgo(4, 2, 0), lp: 1,
lp: 1, },
}, {
{ message:
message: "Amet irure quis incididunt voluptate esse. Commodo ea sunt est ipsum tempor nisi laboris voluptate labore elit laborum. Ex irure commodo reprehenderit consequat consequat do ad tempor aliquip deserunt eu. Laboris minim nostrud quis nisi. Dolor occaecat reprehenderit velit dolore exercitation cupidatat et voluptate. Nulla pariatur deserunt esse minim nisi nisi nulla. Sit eiusmod do incididunt sint minim pariatur aute.",
"Amet irure quis incididunt voluptate esse. Commodo ea sunt est ipsum tempor nisi laboris voluptate labore elit laborum. Ex irure commodo reprehenderit consequat consequat do ad tempor aliquip deserunt eu. Laboris minim nostrud quis nisi. Dolor occaecat reprehenderit velit dolore exercitation cupidatat et voluptate. Nulla pariatur deserunt esse minim nisi nisi nulla. Sit eiusmod do incididunt sint minim pariatur aute.", level: "warn",
level: "warn", count: 1,
count: 1, time: timeAgo(5, 2, 44),
time: timeAgo(5, 2, 44), lp: 1,
lp: 1, },
},
]; ];
export default { export default {
title: "Top/Notifications", title: "Top/Notifications",
component: Notifications, component: Notifications,
parameters: { parameters: {
layout: "centered", layout: "centered",
}, },
argTypes: { argTypes: {
notifications: { control: "object" }, notifications: { control: "object" },
}, },
} as Meta<typeof Notifications>; } as Meta<typeof Notifications>;
const Template: StoryFn<typeof Notifications> = (args) => ({ const Template: StoryFn<typeof Notifications> = (args) => ({
components: { Notifications }, components: { Notifications },
setup() { setup() {
return { args }; return { args };
}, },
template: '<Notifications v-bind="args" />', template: '<Notifications v-bind="args" />',
}); });
export const Default = Template.bind({}); export const Default = Template.bind({});
Default.args = { Default.args = {
notifications: notificationsData, notifications: notificationsData,
}; };

View file

@ -3,17 +3,17 @@ import axios from "axios";
const { protocol, hostname, port, pathname } = window.location; const { protocol, hostname, port, pathname } = window.location;
const baseAPI = axios.create({ const baseAPI = axios.create({
baseURL: protocol + "//" + hostname + (port ? ":" + port : "") + pathname, baseURL: protocol + "//" + hostname + (port ? ":" + port : "") + pathname,
}); });
// global error handling // global error handling
baseAPI.interceptors.response.use( baseAPI.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
const url = error.config.baseURL + error.config.url; const url = error.config.baseURL + error.config.url;
const message = `${error.message}: API request failed ${url}`; const message = `${error.message}: API request failed ${url}`;
window.app.raise({ message }); window.app.raise({ message });
} }
); );
export default baseAPI; export default baseAPI;

View file

@ -1,6 +1,6 @@
export interface Provider { export interface Provider {
title: string; title: string;
loggedIn: boolean; loggedIn: boolean;
loginPath: string; loginPath: string;
logoutPath: string; logoutPath: string;
} }

View file

@ -3,99 +3,99 @@ import VehicleIcon from "./VehicleIcon.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
const icons = [ const icons = [
"car", "car",
"bike", "bike",
"bus", "bus",
"moped", "moped",
"motorcycle", "motorcycle",
"rocket", "rocket",
"scooter", "scooter",
"taxi", "taxi",
"tractor", "tractor",
"rickshaw", "rickshaw",
"shuttle", "shuttle",
"van", "van",
"airpurifier", "airpurifier",
"battery", "battery",
"bulb", "bulb",
"climate", "climate",
"coffeemaker", "coffeemaker",
"compute", "compute",
"cooking", "cooking",
"cooler", "cooler",
"desktop", "desktop",
"device", "device",
"dishwasher", "dishwasher",
"dryer", "dryer",
"floorlamp", "floorlamp",
"generic", "generic",
"heater", "heater",
"heatexchange", "heatexchange",
"heatpump", "heatpump",
"kettle", "kettle",
"laundry", "laundry",
"laundry2", "laundry2",
"machine", "machine",
"meter", "meter",
"smartconsumer", "smartconsumer",
"microwave", "microwave",
"pump", "pump",
"tool", "tool",
"waterheater", "waterheater",
]; ];
export default { export default {
title: "VehicleIcon/VehicleIcon", title: "VehicleIcon/VehicleIcon",
component: VehicleIcon, component: VehicleIcon,
argTypes: { argTypes: {
name: { name: {
control: "select", control: "select",
options: icons, options: icons,
}, },
names: { control: "object" }, names: { control: "object" },
size: { size: {
control: "select", control: "select",
options: Object.values(ICON_SIZE), options: Object.values(ICON_SIZE),
defaultValue: ICON_SIZE.XL, defaultValue: ICON_SIZE.XL,
}, },
}, },
} as Meta<typeof VehicleIcon>; } as Meta<typeof VehicleIcon>;
const Template: StoryFn<typeof VehicleIcon> = (args) => ({ const Template: StoryFn<typeof VehicleIcon> = (args) => ({
components: { VehicleIcon }, components: { VehicleIcon },
setup() { setup() {
return { args }; return { args };
}, },
template: '<VehicleIcon v-bind="args" />', template: '<VehicleIcon v-bind="args" />',
}); });
// Single icon story that can be controlled via args // Single icon story that can be controlled via args
export const SingleIcon = Template.bind({}); export const SingleIcon = Template.bind({});
SingleIcon.args = { SingleIcon.args = {
name: "car", name: "car",
size: ICON_SIZE.XL, size: ICON_SIZE.XL,
}; };
// Multiple icons stories // Multiple icons stories
export const TwoCars = Template.bind({}); export const TwoCars = Template.bind({});
TwoCars.args = { TwoCars.args = {
names: ["car", "car"], names: ["car", "car"],
size: ICON_SIZE.XL, size: ICON_SIZE.XL,
}; };
export const CarAndThreeBikes = Template.bind({}); export const CarAndThreeBikes = Template.bind({});
CarAndThreeBikes.args = { CarAndThreeBikes.args = {
names: ["car", "bike", "bike", "bike"], names: ["car", "bike", "bike", "bike"],
size: ICON_SIZE.XL, size: ICON_SIZE.XL,
}; };
// Story showing all icons at once // Story showing all icons at once
export const AllIcons = () => ({ export const AllIcons = () => ({
components: { VehicleIcon }, components: { VehicleIcon },
setup() { setup() {
return { icons }; return { icons };
}, },
template: ` template: `
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 30px;"> <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 30px;">
<div v-for="icon in icons" :key="icon" style="display: flex; flex-direction: column; align-items: center; gap: 10px;"> <div v-for="icon in icons" :key="icon" style="display: flex; flex-direction: column; align-items: center; gap: 10px;">
<VehicleIcon :name="icon" size="xl" /> <VehicleIcon :name="icon" size="xl" />

View file

@ -3,296 +3,293 @@ import { describe, expect, test } from "vitest";
import Status from "./Status.vue"; import Status from "./Status.vue";
import { CURRENCY } from "@/types/evcc"; import { CURRENCY } from "@/types/evcc";
const serializeData = (data: Object) => (data ? `:${JSON.stringify(data)}` : ""); const serializeData = (data: object) => (data ? `:${JSON.stringify(data)}` : "");
config.global.mocks["$t"] = (key: string, data: Object) => `${key}${serializeData(data)}`; config.global.mocks["$t"] = (key: string, data: object) => `${key}${serializeData(data)}`;
config.global.mocks["$i18n"] = { locale: "de-DE" }; config.global.mocks["$i18n"] = { locale: "de-DE" };
const allEntries = { const allEntries = {
pvtimer: false, pvtimer: false,
phasetimer: false, phasetimer: false,
solar: false, solar: false,
climater: false, climater: false,
minsoc: false, minsoc: false,
limit: false, limit: false,
smartcost: false, smartcost: false,
planactive: false, planactive: false,
planstart: false, planstart: false,
}; };
const expectEntries = (props: InstanceType<typeof Status>["$props"], entries: Object) => { const expectEntries = (props: InstanceType<typeof Status>["$props"], entries: object) => {
const expectedEntries = { ...allEntries, ...entries }; const expectedEntries = { ...allEntries, ...entries };
const wrapper = mount(Status, { props }); const wrapper = mount(Status, { props });
Object.entries(expectedEntries).forEach(([key, value]) => { Object.entries(expectedEntries).forEach(([key, value]) => {
const selector = `[data-testid=vehicle-status-${key}]`; const selector = `[data-testid=vehicle-status-${key}]`;
if (typeof value === "boolean") { if (typeof value === "boolean") {
expect(wrapper.find(selector).exists(), selector).eq(value); expect(wrapper.find(selector).exists(), selector).eq(value);
} else { } else {
expect(wrapper.find(selector).exists(), selector).eq(true); expect(wrapper.find(selector).exists(), selector).eq(true);
expect(wrapper.find(selector).text(), selector).eq(value); expect(wrapper.find(selector).text(), selector).eq(value);
} }
}); });
}; };
describe("basics", () => { describe("basics", () => {
test("no vehicle is connected", () => { test("no vehicle is connected", () => {
expectEntries({ connected: false }, { charger: "main.vehicleStatus.disconnected" }); expectEntries({ connected: false }, { charger: "main.vehicleStatus.disconnected" });
}); });
test("vehicle is connected", () => { test("vehicle is connected", () => {
expectEntries({ connected: true }, { charger: "main.vehicleStatus.connected" }); expectEntries({ connected: true }, { charger: "main.vehicleStatus.connected" });
}); });
test("show waiting for vehicle if charger is enabled but not charging", () => { test("show waiting for vehicle if charger is enabled but not charging", () => {
expectEntries( expectEntries(
{ enabled: true, connected: true }, { enabled: true, connected: true },
{ charger: "main.vehicleStatus.waitForVehicle" } { charger: "main.vehicleStatus.waitForVehicle" }
); );
}); });
test("vehicle is charging", () => { test("vehicle is charging", () => {
expectEntries( expectEntries({ connected: true, charging: true }, { charger: "main.vehicleStatus.charging" });
{ connected: true, charging: true }, });
{ charger: "main.vehicleStatus.charging" }
);
});
}); });
describe("min charge", () => { describe("min charge", () => {
test("active when vehicle soc is below", () => { test("active when vehicle soc is below", () => {
expectEntries( expectEntries(
{ connected: true, minSoc: 20, vehicleSoc: 10 }, { connected: true, minSoc: 20, vehicleSoc: 10 },
{ charger: "main.vehicleStatus.connected", minsoc: "20 %" } { charger: "main.vehicleStatus.connected", minsoc: "20 %" }
); );
}); });
test("not active when vehicle soc is above", () => { test("not active when vehicle soc is above", () => {
expectEntries( expectEntries(
{ connected: true, minSoc: 20, vehicleSoc: 21 }, { connected: true, minSoc: 20, vehicleSoc: 21 },
{ charger: "main.vehicleStatus.connected", minsoc: false } { charger: "main.vehicleStatus.connected", minsoc: false }
); );
}); });
test("not active when vehicle soc is equal", () => { test("not active when vehicle soc is equal", () => {
expectEntries( expectEntries(
{ connected: true, minSoc: 20, vehicleSoc: 20 }, { connected: true, minSoc: 20, vehicleSoc: 20 },
{ charger: "main.vehicleStatus.connected", minsoc: false } { charger: "main.vehicleStatus.connected", minsoc: false }
); );
}); });
test("not active when limit is 0", () => { test("not active when limit is 0", () => {
expectEntries( expectEntries(
{ connected: true, minSoc: 0, vehicleSoc: 10 }, { connected: true, minSoc: 0, vehicleSoc: 10 },
{ charger: "main.vehicleStatus.connected", minsoc: false } { charger: "main.vehicleStatus.connected", minsoc: false }
); );
}); });
}); });
describe("plan", () => { describe("plan", () => {
const effectivePlanTime = "2020-03-16T06:00:00Z"; const effectivePlanTime = "2020-03-16T06:00:00Z";
const planProjectedStart = "2020-03-16T02:00:00Z"; const planProjectedStart = "2020-03-16T02:00:00Z";
const planProjectedEnd = "2020-03-16T05:00:00Z"; const planProjectedEnd = "2020-03-16T05:00:00Z";
test("charging if target time is set, status is charging but planned slot is not active", () => { test("charging if target time is set, status is charging but planned slot is not active", () => {
expectEntries( expectEntries(
{ effectivePlanTime, charging: true, connected: true }, { effectivePlanTime, charging: true, connected: true },
{ charger: "main.vehicleStatus.charging" } { charger: "main.vehicleStatus.charging" }
); );
}); });
test("active if target time is set, status is charging and planned slot is active", () => { test("active if target time is set, status is charging and planned slot is active", () => {
expectEntries( expectEntries(
{ planProjectedEnd, planActive: true, charging: true, connected: true }, { planProjectedEnd, planActive: true, charging: true, connected: true },
{ charger: "main.vehicleStatus.charging", planactive: "Mo 06:00" } { charger: "main.vehicleStatus.charging", planactive: "Mo 06:00" }
); );
}); });
test("waiting for vehicle if a target time is set, the charger is enabled but not charging", () => { test("waiting for vehicle if a target time is set, the charger is enabled but not charging", () => {
expectEntries( expectEntries(
{ planProjectedEnd, planActive: true, enabled: true, connected: true }, { planProjectedEnd, planActive: true, enabled: true, connected: true },
{ charger: "main.vehicleStatus.waitForVehicle", planactive: "Mo 06:00" } { charger: "main.vehicleStatus.waitForVehicle", planactive: "Mo 06:00" }
); );
}); });
test("show projected start if not enabled yet", () => { test("show projected start if not enabled yet", () => {
expectEntries( expectEntries(
{ effectivePlanTime, planProjectedStart, connected: true }, { effectivePlanTime, planProjectedStart, connected: true },
{ charger: "main.vehicleStatus.connected", planstart: "Mo 03:00" } { charger: "main.vehicleStatus.connected", planstart: "Mo 03:00" }
); );
}); });
test("dont show plan status if plan is disabled (e.g. off, fast mode)", () => { test("dont show plan status if plan is disabled (e.g. off, fast mode)", () => {
expectEntries( expectEntries(
{ {
effectivePlanTime, effectivePlanTime,
planActive: true, planActive: true,
charging: true, charging: true,
connected: true, connected: true,
chargingPlanDisabled: true, chargingPlanDisabled: true,
}, },
{ charger: "main.vehicleStatus.charging" } { charger: "main.vehicleStatus.charging" }
); );
expectEntries( expectEntries(
{ {
effectivePlanTime, effectivePlanTime,
planActive: true, planActive: true,
charging: false, charging: false,
enabled: true, enabled: true,
connected: true, connected: true,
chargingPlanDisabled: true, chargingPlanDisabled: true,
}, },
{ charger: "main.vehicleStatus.waitForVehicle" } { charger: "main.vehicleStatus.waitForVehicle" }
); );
expectEntries( expectEntries(
{ effectivePlanTime, planProjectedStart, connected: true, chargingPlanDisabled: true }, { effectivePlanTime, planProjectedStart, connected: true, chargingPlanDisabled: true },
{ charger: "main.vehicleStatus.connected" } { charger: "main.vehicleStatus.connected" }
); );
}); });
}); });
describe("climating", () => { describe("climating", () => {
test("show climating status", () => { test("show climating status", () => {
expectEntries( expectEntries(
{ connected: true, enabled: true, vehicleClimaterActive: true, charging: true }, { connected: true, enabled: true, vehicleClimaterActive: true, charging: true },
{ charger: "main.vehicleStatus.charging", climater: true } { charger: "main.vehicleStatus.charging", climater: true }
); );
expectEntries( expectEntries(
{ connected: true, enabled: true, vehicleClimaterActive: true, charging: false }, { connected: true, enabled: true, vehicleClimaterActive: true, charging: false },
{ charger: "main.vehicleStatus.waitForVehicle", climater: true } { charger: "main.vehicleStatus.waitForVehicle", climater: true }
); );
}); });
test("only show climating if enabled", () => { test("only show climating if enabled", () => {
expectEntries( expectEntries(
{ connected: true, enabled: false, vehicleClimaterActive: true, charging: false }, { connected: true, enabled: false, vehicleClimaterActive: true, charging: false },
{ charger: "main.vehicleStatus.connected", climater: true } { charger: "main.vehicleStatus.connected", climater: true }
); );
}); });
}); });
describe("timer", () => { describe("timer", () => {
test("show pv enable timer if not enabled yet and timer exists", () => { test("show pv enable timer if not enabled yet and timer exists", () => {
expectEntries( expectEntries(
{ {
pvAction: "enable", pvAction: "enable",
connected: true, connected: true,
pvRemainingInterpolated: 90, pvRemainingInterpolated: 90,
}, },
{ charger: "main.vehicleStatus.connected", pvtimer: "1:30\u202Fm" } { charger: "main.vehicleStatus.connected", pvtimer: "1:30\u202Fm" }
); );
}); });
test("don't show pv enable timer if value is zero", () => { test("don't show pv enable timer if value is zero", () => {
expectEntries( expectEntries(
{ {
pvAction: "enable", pvAction: "enable",
connected: true, connected: true,
pvRemainingInterpolated: 0, pvRemainingInterpolated: 0,
}, },
{ charger: "main.vehicleStatus.connected" } { charger: "main.vehicleStatus.connected" }
); );
}); });
test("show pv disable timer if charging and timer exists", () => { test("show pv disable timer if charging and timer exists", () => {
expectEntries( expectEntries(
{ {
pvAction: "disable", pvAction: "disable",
connected: true, connected: true,
charging: true, charging: true,
pvRemainingInterpolated: 90, pvRemainingInterpolated: 90,
}, },
{ charger: "main.vehicleStatus.charging", pvtimer: "1:30\u202Fm" } { charger: "main.vehicleStatus.charging", pvtimer: "1:30\u202Fm" }
); );
}); });
test("show phase enable timer if it exists", () => { test("show phase enable timer if it exists", () => {
expectEntries( expectEntries(
{ {
phaseAction: "scale1p", phaseAction: "scale1p",
connected: true, connected: true,
charging: true, charging: true,
phaseRemainingInterpolated: 91, phaseRemainingInterpolated: 91,
}, },
{ charger: "main.vehicleStatus.charging", phasetimer: "1:31\u202Fm" } { charger: "main.vehicleStatus.charging", phasetimer: "1:31\u202Fm" }
); );
}); });
test("show phase disable timer if it exists", () => { test("show phase disable timer if it exists", () => {
expectEntries( expectEntries(
{ {
phaseAction: "scale3p", phaseAction: "scale3p",
connected: true, connected: true,
charging: true, charging: true,
phaseRemainingInterpolated: 91, phaseRemainingInterpolated: 91,
}, },
{ charger: "main.vehicleStatus.charging", phasetimer: "1:31\u202Fm" } { charger: "main.vehicleStatus.charging", phasetimer: "1:31\u202Fm" }
); );
}); });
}); });
describe("vehicle target soc", () => { describe("vehicle target soc", () => {
test("show target reached if charger enabled but soc has reached vehicle limit", () => { test("show target reached if charger enabled but soc has reached vehicle limit", () => {
expectEntries( expectEntries(
{ {
connected: true, connected: true,
enabled: true, enabled: true,
vehicleLimitSoc: 70, vehicleLimitSoc: 70,
vehicleSoc: 70, vehicleSoc: 70,
}, },
{ charger: "main.vehicleStatus.finished", limit: "70 %" } { charger: "main.vehicleStatus.finished", limit: "70 %" }
); );
}); });
test("show reached message even if vehicle is slightly below its limit", () => { test("show reached message even if vehicle is slightly below its limit", () => {
expectEntries( expectEntries(
{ {
connected: true, connected: true,
enabled: true, enabled: true,
vehicleLimitSoc: 70, vehicleLimitSoc: 70,
vehicleSoc: 69, vehicleSoc: 69,
}, },
{ charger: "main.vehicleStatus.finished", limit: "70 %" } { charger: "main.vehicleStatus.finished", limit: "70 %" }
); );
}); });
}); });
describe("smart grid charging", () => { describe("smart grid charging", () => {
test("show clean energy message", () => { test("show clean energy message", () => {
expectEntries( expectEntries(
{ {
connected: true, connected: true,
enabled: true, enabled: true,
charging: true, charging: true,
tariffCo2: 600, tariffCo2: 600,
smartCostLimit: 500, smartCostLimit: 500,
smartCostType: "co2", smartCostType: "co2",
}, },
{ charger: "main.vehicleStatus.charging", smartcost: "≤ 500 g" } { charger: "main.vehicleStatus.charging", smartcost: "≤ 500 g" }
); );
}); });
test("show clean energy message if active", () => { test("show clean energy message if active", () => {
expectEntries( expectEntries(
{ {
connected: true, connected: true,
enabled: true, enabled: true,
charging: true, charging: true,
tariffCo2: 400, tariffCo2: 400,
smartCostLimit: 500, smartCostLimit: 500,
smartCostType: "co2", smartCostType: "co2",
smartCostActive: true, smartCostActive: true,
}, },
{ charger: "main.vehicleStatus.charging", smartcost: "400 g ≤ 500 g" } { charger: "main.vehicleStatus.charging", smartcost: "400 g ≤ 500 g" }
); );
}); });
test("show cheap energy message", () => { test("show cheap energy message", () => {
expectEntries( expectEntries(
{ {
connected: true, connected: true,
enabled: true, enabled: true,
charging: true, charging: true,
tariffGrid: 0.3, tariffGrid: 0.3,
smartCostLimit: 0.29, smartCostLimit: 0.29,
currency: CURRENCY.EUR, currency: CURRENCY.EUR,
}, },
{ charger: "main.vehicleStatus.charging", smartcost: "≤ 29,0 ct" } { charger: "main.vehicleStatus.charging", smartcost: "≤ 29,0 ct" }
); );
}); });
test("show cheap energy message if active", () => { test("show cheap energy message if active", () => {
expectEntries( expectEntries(
{ {
connected: true, connected: true,
enabled: true, enabled: true,
charging: true, charging: true,
tariffGrid: 0.28, tariffGrid: 0.28,
smartCostLimit: 0.29, smartCostLimit: 0.29,
currency: CURRENCY.EUR, currency: CURRENCY.EUR,
smartCostActive: true, smartCostActive: true,
}, },
{ charger: "main.vehicleStatus.charging", smartcost: "28,0 ct ≤ 29,0 ct" } { charger: "main.vehicleStatus.charging", smartcost: "28,0 ct ≤ 29,0 ct" }
); );
}); });
}); });

View file

@ -3,185 +3,185 @@ import Vehicle from "./Vehicle.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
const baseState = { const baseState = {
vehicle: { vehicle: {
title: "Mein Auto", title: "Mein Auto",
icon: "car", icon: "car",
capacity: 72, capacity: 72,
features: [], features: [],
name: "", name: "",
repeatingPlans: [], repeatingPlans: [],
}, },
enabled: false, enabled: false,
connected: true, connected: true,
vehicleName: "meinauto", vehicleName: "meinauto",
vehicleSoc: 42.742, vehicleSoc: 42.742,
vehicleRange: 231, vehicleRange: 231,
limitSoc: 90, limitSoc: 90,
chargedEnergy: 14123, chargedEnergy: 14123,
socBasedCharging: true, socBasedCharging: true,
id: 0, id: 0,
}; };
export default { export default {
title: "Vehicles/Vehicle", title: "Vehicles/Vehicle",
component: Vehicle, component: Vehicle,
argTypes: { argTypes: {
chargedEnergy: { control: "number" }, chargedEnergy: { control: "number" },
charging: { control: "boolean" }, charging: { control: "boolean" },
vehicleClimaterActive: { control: "boolean" }, vehicleClimaterActive: { control: "boolean" },
vehicleWelcomeActive: { control: "boolean" }, vehicleWelcomeActive: { control: "boolean" },
connected: { control: "boolean" }, connected: { control: "boolean" },
currency: { control: "text" }, currency: { control: "text" },
effectiveLimitSoc: { control: "number" }, effectiveLimitSoc: { control: "number" },
effectivePlanSoc: { control: "number" }, effectivePlanSoc: { control: "number" },
effectivePlanTime: { control: "text" }, effectivePlanTime: { control: "text" },
batteryBoostActive: { control: "boolean" }, batteryBoostActive: { control: "boolean" },
enabled: { control: "boolean" }, enabled: { control: "boolean" },
heating: { control: "boolean" }, heating: { control: "boolean" },
id: { control: "text" }, id: { control: "text" },
integratedDevice: { control: "boolean" }, integratedDevice: { control: "boolean" },
limitEnergy: { control: "number" }, limitEnergy: { control: "number" },
mode: { control: "text" }, mode: { control: "text" },
chargerStatusReason: { control: "text" }, chargerStatusReason: { control: "text" },
vehicle: { control: "object" }, vehicle: { control: "object" },
vehicleName: { control: "text" }, vehicleName: { control: "text" },
vehicleRange: { control: "number" }, vehicleRange: { control: "number" },
vehicleSoc: { control: "number" }, vehicleSoc: { control: "number" },
vehicleLimitSoc: { control: "number" }, vehicleLimitSoc: { control: "number" },
socBasedCharging: { control: "boolean" }, socBasedCharging: { control: "boolean" },
}, },
} as Meta<typeof Vehicle>; } as Meta<typeof Vehicle>;
const Template: StoryFn<typeof Vehicle> = (args) => ({ const Template: StoryFn<typeof Vehicle> = (args) => ({
components: { Vehicle }, components: { Vehicle },
setup() { setup() {
return { args }; return { args };
}, },
template: '<Vehicle v-bind="args" />', template: '<Vehicle v-bind="args" />',
}); });
export const Disconnected = Template.bind({}); export const Disconnected = Template.bind({});
Disconnected.args = { Disconnected.args = {
...baseState, ...baseState,
connected: false, connected: false,
}; };
export const Connected = Template.bind({}); export const Connected = Template.bind({});
Connected.args = { Connected.args = {
...baseState, ...baseState,
}; };
export const Ready = Template.bind({}); export const Ready = Template.bind({});
Ready.args = { Ready.args = {
...baseState, ...baseState,
enabled: true, enabled: true,
}; };
export const Charging = Template.bind({}); export const Charging = Template.bind({});
Charging.args = { Charging.args = {
...baseState, ...baseState,
enabled: true, enabled: true,
charging: true, charging: true,
}; };
export const UnknownVehicle = Template.bind({}); export const UnknownVehicle = Template.bind({});
UnknownVehicle.args = { UnknownVehicle.args = {
...baseState, ...baseState,
enabled: true, enabled: true,
charging: true, charging: true,
vehicleName: "", vehicleName: "",
socBasedCharging: false, socBasedCharging: false,
vehicle: { ...baseState.vehicle, capacity: undefined }, vehicle: { ...baseState.vehicle, capacity: undefined },
mode: CHARGE_MODE.PV, mode: CHARGE_MODE.PV,
}; };
export const OfflineVehicle = Template.bind({}); export const OfflineVehicle = Template.bind({});
OfflineVehicle.args = { OfflineVehicle.args = {
...baseState, ...baseState,
enabled: true, enabled: true,
charging: true, charging: true,
socBasedCharging: false, socBasedCharging: false,
vehicle: { vehicle: {
...baseState.vehicle, ...baseState.vehicle,
title: "Opel Corsa-e", title: "Opel Corsa-e",
capacity: 72, capacity: 72,
features: ["Offline"], features: ["Offline"],
}, },
mode: CHARGE_MODE.PV, mode: CHARGE_MODE.PV,
}; };
export const OfflineVehicleWithTarget = Template.bind({}); export const OfflineVehicleWithTarget = Template.bind({});
OfflineVehicleWithTarget.args = { OfflineVehicleWithTarget.args = {
...baseState, ...baseState,
enabled: true, enabled: true,
charging: true, charging: true,
socBasedCharging: false, socBasedCharging: false,
vehicle: { vehicle: {
...baseState.vehicle, ...baseState.vehicle,
title: "Opel Corsa-e", title: "Opel Corsa-e",
capacity: 72, capacity: 72,
features: ["Offline"], features: ["Offline"],
}, },
mode: CHARGE_MODE.PV, mode: CHARGE_MODE.PV,
}; };
export const VehicleLimit = Template.bind({}); export const VehicleLimit = Template.bind({});
VehicleLimit.args = { VehicleLimit.args = {
...baseState, ...baseState,
enabled: true, enabled: true,
charging: true, charging: true,
vehicleLimitSoc: 80, vehicleLimitSoc: 80,
}; };
export const VehicleLimitReached = Template.bind({}); export const VehicleLimitReached = Template.bind({});
VehicleLimitReached.args = { VehicleLimitReached.args = {
...baseState, ...baseState,
enabled: true, enabled: true,
vehicleLimitSoc: 80, vehicleLimitSoc: 80,
vehicleSoc: 80, vehicleSoc: 80,
}; };
export const TargetChargePlanned = Template.bind({}); export const TargetChargePlanned = Template.bind({});
TargetChargePlanned.args = { TargetChargePlanned.args = {
...baseState, ...baseState,
mode: CHARGE_MODE.PV, mode: CHARGE_MODE.PV,
}; };
export const TargetChargeActive = Template.bind({}); export const TargetChargeActive = Template.bind({});
TargetChargeActive.args = { TargetChargeActive.args = {
...baseState, ...baseState,
enabled: true, enabled: true,
charging: true, charging: true,
mode: CHARGE_MODE.PV, mode: CHARGE_MODE.PV,
}; };
export const SmartChargeCostLimitActive = Template.bind({}); export const SmartChargeCostLimitActive = Template.bind({});
SmartChargeCostLimitActive.args = { SmartChargeCostLimitActive.args = {
...baseState, ...baseState,
enabled: true, enabled: true,
charging: true, charging: true,
smartCostLimit: 0.13, smartCostLimit: 0.13,
mode: CHARGE_MODE.PV, mode: CHARGE_MODE.PV,
}; };
export const PvEnableTimer = Template.bind({}); export const PvEnableTimer = Template.bind({});
PvEnableTimer.args = { PvEnableTimer.args = {
...baseState, ...baseState,
pvAction: "enable", pvAction: "enable",
pvRemainingInterpolated: 32, pvRemainingInterpolated: 32,
}; };
export const PvDisableTimer = Template.bind({}); export const PvDisableTimer = Template.bind({});
PvDisableTimer.args = { PvDisableTimer.args = {
...baseState, ...baseState,
enabled: true, enabled: true,
charging: true, charging: true,
pvAction: "disable", pvAction: "disable",
pvRemainingInterpolated: 155, pvRemainingInterpolated: 155,
}; };
export const VehicleSwitch = Template.bind({}); export const VehicleSwitch = Template.bind({});
VehicleSwitch.args = { VehicleSwitch.args = {
...baseState, ...baseState,
vehicles: ["Blauer e-Golf", "Weißes Model 3"], vehicles: ["Blauer e-Golf", "Weißes Model 3"],
}; };

View file

@ -2,15 +2,15 @@ import type { App } from "vue";
import settings from "./settings"; import settings from "./settings";
export function setHiddenFeatures(value: boolean) { export function setHiddenFeatures(value: boolean) {
settings.hiddenFeatures = value; settings.hiddenFeatures = value;
} }
export function getHiddenFeatures() { export function getHiddenFeatures() {
return settings.hiddenFeatures; return settings.hiddenFeatures;
} }
export default { export default {
install: (app: App) => { install: (app: App) => {
app.config.globalProperties.$hiddenFeatures = getHiddenFeatures; app.config.globalProperties.$hiddenFeatures = getHiddenFeatures;
}, },
}; };

View file

@ -6,36 +6,36 @@ import settings from "./settings";
// https://github.com/joker-x/languages.js/blob/master/languages.json // https://github.com/joker-x/languages.js/blob/master/languages.json
export const LOCALES = { export const LOCALES = {
ar: ["Arabic", "العربية"], ar: ["Arabic", "العربية"],
bg: ["Bulgarian", "Български"], bg: ["Bulgarian", "Български"],
ca: ["Catalan", "Català"], ca: ["Catalan", "Català"],
cs: ["Czech", "Česky"], cs: ["Czech", "Česky"],
da: ["Danish", "Dansk"], da: ["Danish", "Dansk"],
de: ["German", "Deutsch"], de: ["German", "Deutsch"],
el: ["Greek", "Ελληνικά"], el: ["Greek", "Ελληνικά"],
en: ["English", "English"], en: ["English", "English"],
es: ["Spanish", "Español"], es: ["Spanish", "Español"],
et: ["Estonian", "Eesti"], et: ["Estonian", "Eesti"],
fi: ["Finnish", "Suomi"], fi: ["Finnish", "Suomi"],
fr: ["French", "Français"], fr: ["French", "Français"],
hr: ["Croatian", "Hrvatski"], hr: ["Croatian", "Hrvatski"],
hu: ["Hungarian", "Magyar"], hu: ["Hungarian", "Magyar"],
it: ["Italian", "Italiano"], it: ["Italian", "Italiano"],
lb: ["Luxembourgish", "Lëtzebuergesch"], lb: ["Luxembourgish", "Lëtzebuergesch"],
lt: ["Lithuanian", "Lietuvių"], lt: ["Lithuanian", "Lietuvių"],
nl: ["Dutch", "Nederlands"], nl: ["Dutch", "Nederlands"],
no: ["Norwegian", "Norsk"], no: ["Norwegian", "Norsk"],
pl: ["Polish", "Polski"], pl: ["Polish", "Polski"],
pt: ["Portuguese", "Português"], pt: ["Portuguese", "Português"],
ro: ["Romanian", "Română"], ro: ["Romanian", "Română"],
ru: ["Russian", "Русский"], ru: ["Russian", "Русский"],
sk: ["Slovak", "Slovenčina"], sk: ["Slovak", "Slovenčina"],
sl: ["Slovenian", "Slovenščina"], sl: ["Slovenian", "Slovenščina"],
sv: ["Swedish", "Svenska"], sv: ["Swedish", "Svenska"],
ta: ["Tamil", "தமிழ்"], ta: ["Tamil", "தமிழ்"],
tr: ["Turkish", "Türkçe"], tr: ["Turkish", "Türkçe"],
uk: ["Ukrainian", "Українська"], uk: ["Ukrainian", "Українська"],
"zh-Hans": ["Chinese (Simplified)", "简体中文"], "zh-Hans": ["Chinese (Simplified)", "简体中文"],
}; };
export const DEFAULT_LOCALE = "en"; export const DEFAULT_LOCALE = "en";
@ -43,71 +43,71 @@ const htmlLang = document.querySelector("html")?.getAttribute("lang");
const DEFAULT_BROWSER_LOCALE = htmlLang?.length == 2 ? htmlLang : DEFAULT_LOCALE; const DEFAULT_BROWSER_LOCALE = htmlLang?.length == 2 ? htmlLang : DEFAULT_LOCALE;
export function getLocalePreference() { export function getLocalePreference() {
return settings.locale; return settings.locale;
} }
export function removeLocalePreference(i18n: VueI18nInstance) { export function removeLocalePreference(i18n: VueI18nInstance) {
settings.locale = null; settings.locale = null;
setI18nLanguage(i18n, DEFAULT_BROWSER_LOCALE as typeof i18n.locale); setI18nLanguage(i18n, DEFAULT_BROWSER_LOCALE as typeof i18n.locale);
ensureCurrentLocaleMessages(i18n); ensureCurrentLocaleMessages(i18n);
} }
export function setLocalePreference(i18n: VueI18nInstance, locale: typeof i18n.locale) { export function setLocalePreference(i18n: VueI18nInstance, locale: typeof i18n.locale) {
if (!LOCALES.hasOwnProperty(locale)) { if (!(locale in LOCALES)) {
console.error("unknown locale", locale); console.error("unknown locale", locale);
return; return;
} }
settings.locale = locale; settings.locale = locale;
setI18nLanguage(i18n, locale); setI18nLanguage(i18n, locale);
ensureCurrentLocaleMessages(i18n); ensureCurrentLocaleMessages(i18n);
} }
function getLocale() { function getLocale() {
return getLocalePreference() || DEFAULT_BROWSER_LOCALE; return getLocalePreference() || DEFAULT_BROWSER_LOCALE;
} }
export default function setupI18n() { export default function setupI18n() {
const i18n = createI18n({ const i18n = createI18n({
legacy: true, legacy: true,
silentFallbackWarn: true, silentFallbackWarn: true,
silentTranslationWarn: true, silentTranslationWarn: true,
locale: DEFAULT_LOCALE, locale: DEFAULT_LOCALE,
fallbackLocale: DEFAULT_LOCALE, fallbackLocale: DEFAULT_LOCALE,
messages: { "en-US": en }, messages: { "en-US": en },
}); });
setI18nLanguage(i18n.global, getLocale()); setI18nLanguage(i18n.global, getLocale());
return i18n; return i18n;
} }
export function setI18nLanguage(i18n: VueI18nInstance, locale: typeof i18n.locale) { export function setI18nLanguage(i18n: VueI18nInstance, locale: typeof i18n.locale) {
i18n.locale = locale; i18n.locale = locale;
document.querySelector("html")?.setAttribute("lang", locale); document.querySelector("html")?.setAttribute("lang", locale);
} }
async function loadLocaleMessages(i18n: VueI18nInstance, locale: typeof i18n.locale) { async function loadLocaleMessages(i18n: VueI18nInstance, locale: typeof i18n.locale) {
try { try {
const response = await i18nApi.get(`${locale}.json`, { const response = await i18nApi.get(`${locale}.json`, {
params: { v: window.evcc?.version }, params: { v: window.evcc?.version },
}); });
if ("setLocaleMessage" in i18n) { if ("setLocaleMessage" in i18n) {
i18n.setLocaleMessage(locale, response.data); i18n.setLocaleMessage(locale, response.data);
} }
} catch (e) { } catch (e) {
console.error(`unable to load translation for [${locale}]`, e); console.error(`unable to load translation for [${locale}]`, e);
} }
return nextTick(); return nextTick();
} }
export async function ensureCurrentLocaleMessages(i18n: VueI18nInstance) { export async function ensureCurrentLocaleMessages(i18n: VueI18nInstance) {
const { locale } = i18n; const { locale } = i18n;
if (!i18n.availableLocales.includes(locale)) { if (!i18n.availableLocales.includes(locale)) {
await loadLocaleMessages(i18n, locale); await loadLocaleMessages(i18n, locale);
} }
} }
export function docsPrefix() { export function docsPrefix() {
const locale = getLocale(); const locale = getLocale();
const path = locale === "de" ? "" : `/en`; const path = locale === "de" ? "" : `/en`;
return `https://docs.evcc.io${path}`; return `https://docs.evcc.io${path}`;
} }

View file

@ -1,44 +1,44 @@
export type Breakpoint = "xs" | "sm" | "md" | "lg" | "xl" | "xxl"; export type Breakpoint = "xs" | "sm" | "md" | "lg" | "xl" | "xxl";
interface BreakpointDef { interface BreakpointDef {
name: Breakpoint; name: Breakpoint;
maxWidth: number; maxWidth: number;
} }
const BREAKPOINTS: BreakpointDef[] = [ const BREAKPOINTS: BreakpointDef[] = [
{ name: "xs", maxWidth: 575 }, { name: "xs", maxWidth: 575 },
{ name: "sm", maxWidth: 767 }, { name: "sm", maxWidth: 767 },
{ name: "md", maxWidth: 991 }, { name: "md", maxWidth: 991 },
{ name: "lg", maxWidth: 1199 }, { name: "lg", maxWidth: 1199 },
{ name: "xl", maxWidth: 1399 }, { name: "xl", maxWidth: 1399 },
{ name: "xxl", maxWidth: Infinity }, { name: "xxl", maxWidth: Infinity },
]; ];
export default { export default {
data() { data() {
return { return {
breakpoint: "md" as Breakpoint, breakpoint: "md" as Breakpoint,
}; };
}, },
methods: { methods: {
updateBreakpoint(): void { updateBreakpoint(): void {
const width: number = window.innerWidth; const width: number = window.innerWidth;
const self = this as any; const self = this as any;
for (const bp of BREAKPOINTS) { for (const bp of BREAKPOINTS) {
if (width <= bp.maxWidth) { if (width <= bp.maxWidth) {
self.breakpoint = bp.name; self.breakpoint = bp.name;
return; return;
} }
} }
}, },
}, },
mounted() { mounted() {
const self = this as any; const self = this as any;
self.updateBreakpoint(); self.updateBreakpoint();
window.addEventListener("resize", self.updateBreakpoint); window.addEventListener("resize", self.updateBreakpoint);
}, },
beforeDestroy() { beforeDestroy() {
const self = this as any; const self = this as any;
window.removeEventListener("resize", self.updateBreakpoint); window.removeEventListener("resize", self.updateBreakpoint);
}, },
}; };

View file

@ -2,22 +2,22 @@ import { defineComponent } from "vue";
import type { State } from "@/types/evcc"; import type { State } from "@/types/evcc";
export default defineComponent({ export default defineComponent({
methods: { methods: {
// collect all target component properties from current instance // collect all target component properties from current instance
collectProps(component: any, state?: State) { collectProps(component: any, state?: State) {
let data: Record<string, any> = {}; const data: Record<string, any> = {};
for (const prop in component.props) { for (const prop in component.props) {
const p = prop as keyof State; const p = prop as keyof State;
// check in optional state // check in optional state
if (state && p in state) { if (state && p in state) {
data[p] = state[p]; data[p] = state[p];
} }
// check in current instance // check in current instance
if (p in this) { if (p in this) {
data[p] = (this as Partial<State>)[p]; data[p] = (this as Partial<State>)[p];
} }
} }
return data; return data;
}, },
}, },
}); });

View file

@ -11,202 +11,204 @@ config.global.mocks["$i18n"] = { locale: "de-DE" };
config.global.mocks["$t"] = (a: string) => a; config.global.mocks["$t"] = (a: string) => a;
const fmt = mount( const fmt = mount(
defineComponent({ defineComponent({
render() {}, mixins: [formatter],
mixins: [formatter], render() {
}) return null;
},
})
).vm; ).vm;
describe("fmtW", () => { describe("fmtW", () => {
test("should format with units", () => { test("should format with units", () => {
expect(fmt.fmtW(0, POWER_UNIT.AUTO)).eq("0,0 kW"); expect(fmt.fmtW(0, POWER_UNIT.AUTO)).eq("0,0 kW");
expect(fmt.fmtW(1200000, POWER_UNIT.AUTO)).eq("1.200,0 kW"); expect(fmt.fmtW(1200000, POWER_UNIT.AUTO)).eq("1.200,0 kW");
expect(fmt.fmtW(0, POWER_UNIT.MW)).eq("0,0 MW"); expect(fmt.fmtW(0, POWER_UNIT.MW)).eq("0,0 MW");
expect(fmt.fmtW(1200000, POWER_UNIT.MW)).eq("1,2 MW"); expect(fmt.fmtW(1200000, POWER_UNIT.MW)).eq("1,2 MW");
expect(fmt.fmtW(0, POWER_UNIT.KW)).eq("0,0 kW"); expect(fmt.fmtW(0, POWER_UNIT.KW)).eq("0,0 kW");
expect(fmt.fmtW(1200000, POWER_UNIT.KW)).eq("1.200,0 kW"); expect(fmt.fmtW(1200000, POWER_UNIT.KW)).eq("1.200,0 kW");
expect(fmt.fmtW(0, POWER_UNIT.W)).eq("0 W"); expect(fmt.fmtW(0, POWER_UNIT.W)).eq("0 W");
expect(fmt.fmtW(1200000, POWER_UNIT.W)).eq("1.200.000 W"); expect(fmt.fmtW(1200000, POWER_UNIT.W)).eq("1.200.000 W");
}); });
test("should format without units", () => { test("should format without units", () => {
expect(fmt.fmtW(0, POWER_UNIT.AUTO, false)).eq("0,0"); expect(fmt.fmtW(0, POWER_UNIT.AUTO, false)).eq("0,0");
expect(fmt.fmtW(1200000, POWER_UNIT.AUTO, false)).eq("1.200,0"); expect(fmt.fmtW(1200000, POWER_UNIT.AUTO, false)).eq("1.200,0");
expect(fmt.fmtW(0, POWER_UNIT.MW, false)).eq("0,0"); expect(fmt.fmtW(0, POWER_UNIT.MW, false)).eq("0,0");
expect(fmt.fmtW(1200000, POWER_UNIT.MW, false)).eq("1,2"); expect(fmt.fmtW(1200000, POWER_UNIT.MW, false)).eq("1,2");
expect(fmt.fmtW(0, POWER_UNIT.KW, false)).eq("0,0"); expect(fmt.fmtW(0, POWER_UNIT.KW, false)).eq("0,0");
expect(fmt.fmtW(1200000, POWER_UNIT.KW, false)).eq("1.200,0"); expect(fmt.fmtW(1200000, POWER_UNIT.KW, false)).eq("1.200,0");
expect(fmt.fmtW(0, POWER_UNIT.W, false)).eq("0"); expect(fmt.fmtW(0, POWER_UNIT.W, false)).eq("0");
expect(fmt.fmtW(1200000, POWER_UNIT.W, false)).eq("1.200.000"); expect(fmt.fmtW(1200000, POWER_UNIT.W, false)).eq("1.200.000");
}); });
test("should format a given number of digits", () => { test("should format a given number of digits", () => {
expect(fmt.fmtW(12345, POWER_UNIT.AUTO, true, 0)).eq("12 kW"); expect(fmt.fmtW(12345, POWER_UNIT.AUTO, true, 0)).eq("12 kW");
expect(fmt.fmtW(12345, POWER_UNIT.AUTO, true, 1)).eq("12,3 kW"); expect(fmt.fmtW(12345, POWER_UNIT.AUTO, true, 1)).eq("12,3 kW");
expect(fmt.fmtW(12345, POWER_UNIT.AUTO, true, 2)).eq("12,35 kW"); expect(fmt.fmtW(12345, POWER_UNIT.AUTO, true, 2)).eq("12,35 kW");
expect(fmt.fmtW(12345, POWER_UNIT.MW, true, 0)).eq("0 MW"); expect(fmt.fmtW(12345, POWER_UNIT.MW, true, 0)).eq("0 MW");
expect(fmt.fmtW(12345, POWER_UNIT.MW, true, 1)).eq("0,0 MW"); expect(fmt.fmtW(12345, POWER_UNIT.MW, true, 1)).eq("0,0 MW");
expect(fmt.fmtW(12345, POWER_UNIT.MW, true, 2)).eq("0,01 MW"); expect(fmt.fmtW(12345, POWER_UNIT.MW, true, 2)).eq("0,01 MW");
expect(fmt.fmtW(12345, POWER_UNIT.KW, true, 0)).eq("12 kW"); expect(fmt.fmtW(12345, POWER_UNIT.KW, true, 0)).eq("12 kW");
expect(fmt.fmtW(12345, POWER_UNIT.KW, true, 1)).eq("12,3 kW"); expect(fmt.fmtW(12345, POWER_UNIT.KW, true, 1)).eq("12,3 kW");
expect(fmt.fmtW(12345, POWER_UNIT.KW, true, 2)).eq("12,35 kW"); expect(fmt.fmtW(12345, POWER_UNIT.KW, true, 2)).eq("12,35 kW");
expect(fmt.fmtW(12345, POWER_UNIT.W, true, 0)).eq("12.345 W"); expect(fmt.fmtW(12345, POWER_UNIT.W, true, 0)).eq("12.345 W");
expect(fmt.fmtW(12345, POWER_UNIT.W, true, 1)).eq("12.345,0 W"); expect(fmt.fmtW(12345, POWER_UNIT.W, true, 1)).eq("12.345,0 W");
expect(fmt.fmtW(12345, POWER_UNIT.W, true, 2)).eq("12.345,00 W"); expect(fmt.fmtW(12345, POWER_UNIT.W, true, 2)).eq("12.345,00 W");
}); });
}); });
describe("fmtWh", () => { describe("fmtWh", () => {
test("should format with units", () => { test("should format with units", () => {
expect(fmt.fmtWh(0, POWER_UNIT.AUTO)).eq("0,0 kWh"); expect(fmt.fmtWh(0, POWER_UNIT.AUTO)).eq("0,0 kWh");
expect(fmt.fmtWh(1200000, POWER_UNIT.AUTO)).eq("1.200,0 kWh"); expect(fmt.fmtWh(1200000, POWER_UNIT.AUTO)).eq("1.200,0 kWh");
expect(fmt.fmtWh(0, POWER_UNIT.MW)).eq("0,0 MWh"); expect(fmt.fmtWh(0, POWER_UNIT.MW)).eq("0,0 MWh");
expect(fmt.fmtWh(1200000, POWER_UNIT.MW)).eq("1,2 MWh"); expect(fmt.fmtWh(1200000, POWER_UNIT.MW)).eq("1,2 MWh");
expect(fmt.fmtWh(0, POWER_UNIT.KW)).eq("0,0 kWh"); expect(fmt.fmtWh(0, POWER_UNIT.KW)).eq("0,0 kWh");
expect(fmt.fmtWh(1200000, POWER_UNIT.KW)).eq("1.200,0 kWh"); expect(fmt.fmtWh(1200000, POWER_UNIT.KW)).eq("1.200,0 kWh");
expect(fmt.fmtWh(0, POWER_UNIT.W)).eq("0 Wh"); expect(fmt.fmtWh(0, POWER_UNIT.W)).eq("0 Wh");
expect(fmt.fmtWh(1200000, POWER_UNIT.W)).eq("1.200.000 Wh"); expect(fmt.fmtWh(1200000, POWER_UNIT.W)).eq("1.200.000 Wh");
}); });
test("should format without units", () => { test("should format without units", () => {
expect(fmt.fmtWh(0, POWER_UNIT.AUTO, false)).eq("0,0"); expect(fmt.fmtWh(0, POWER_UNIT.AUTO, false)).eq("0,0");
expect(fmt.fmtWh(1200000, POWER_UNIT.AUTO, false)).eq("1.200,0"); expect(fmt.fmtWh(1200000, POWER_UNIT.AUTO, false)).eq("1.200,0");
expect(fmt.fmtWh(0, POWER_UNIT.MW, false)).eq("0,0"); expect(fmt.fmtWh(0, POWER_UNIT.MW, false)).eq("0,0");
expect(fmt.fmtWh(1200000, POWER_UNIT.MW, false)).eq("1,2"); expect(fmt.fmtWh(1200000, POWER_UNIT.MW, false)).eq("1,2");
expect(fmt.fmtWh(0, POWER_UNIT.KW, false)).eq("0,0"); expect(fmt.fmtWh(0, POWER_UNIT.KW, false)).eq("0,0");
expect(fmt.fmtWh(1200000, POWER_UNIT.KW, false)).eq("1.200,0"); expect(fmt.fmtWh(1200000, POWER_UNIT.KW, false)).eq("1.200,0");
expect(fmt.fmtWh(0, POWER_UNIT.W, false)).eq("0"); expect(fmt.fmtWh(0, POWER_UNIT.W, false)).eq("0");
expect(fmt.fmtWh(1200000, POWER_UNIT.W, false)).eq("1.200.000"); expect(fmt.fmtWh(1200000, POWER_UNIT.W, false)).eq("1.200.000");
}); });
test("should format a given number of digits", () => { test("should format a given number of digits", () => {
expect(fmt.fmtWh(12345, POWER_UNIT.AUTO, true, 0)).eq("12 kWh"); expect(fmt.fmtWh(12345, POWER_UNIT.AUTO, true, 0)).eq("12 kWh");
expect(fmt.fmtWh(12345, POWER_UNIT.AUTO, true, 1)).eq("12,3 kWh"); expect(fmt.fmtWh(12345, POWER_UNIT.AUTO, true, 1)).eq("12,3 kWh");
expect(fmt.fmtWh(12345, POWER_UNIT.AUTO, true, 2)).eq("12,35 kWh"); expect(fmt.fmtWh(12345, POWER_UNIT.AUTO, true, 2)).eq("12,35 kWh");
expect(fmt.fmtWh(12345, POWER_UNIT.MW, true, 0)).eq("0 MWh"); expect(fmt.fmtWh(12345, POWER_UNIT.MW, true, 0)).eq("0 MWh");
expect(fmt.fmtWh(12345, POWER_UNIT.MW, true, 1)).eq("0,0 MWh"); expect(fmt.fmtWh(12345, POWER_UNIT.MW, true, 1)).eq("0,0 MWh");
expect(fmt.fmtWh(12345, POWER_UNIT.MW, true, 2)).eq("0,01 MWh"); expect(fmt.fmtWh(12345, POWER_UNIT.MW, true, 2)).eq("0,01 MWh");
expect(fmt.fmtWh(12345, POWER_UNIT.KW, true, 0)).eq("12 kWh"); expect(fmt.fmtWh(12345, POWER_UNIT.KW, true, 0)).eq("12 kWh");
expect(fmt.fmtWh(12345, POWER_UNIT.KW, true, 1)).eq("12,3 kWh"); expect(fmt.fmtWh(12345, POWER_UNIT.KW, true, 1)).eq("12,3 kWh");
expect(fmt.fmtWh(12345, POWER_UNIT.KW, true, 2)).eq("12,35 kWh"); expect(fmt.fmtWh(12345, POWER_UNIT.KW, true, 2)).eq("12,35 kWh");
expect(fmt.fmtWh(12345, POWER_UNIT.W, true, 0)).eq("12.345 Wh"); expect(fmt.fmtWh(12345, POWER_UNIT.W, true, 0)).eq("12.345 Wh");
expect(fmt.fmtWh(12345, POWER_UNIT.W, true, 1)).eq("12.345,0 Wh"); expect(fmt.fmtWh(12345, POWER_UNIT.W, true, 1)).eq("12.345,0 Wh");
expect(fmt.fmtWh(12345, POWER_UNIT.W, true, 2)).eq("12.345,00 Wh"); expect(fmt.fmtWh(12345, POWER_UNIT.W, true, 2)).eq("12.345,00 Wh");
}); });
}); });
describe("fmtPricePerKWh", () => { describe("fmtPricePerKWh", () => {
test("should format with units", () => { test("should format with units", () => {
expect(fmt.fmtPricePerKWh(0.2, CURRENCY.EUR)).eq("20,0 ct/kWh"); expect(fmt.fmtPricePerKWh(0.2, CURRENCY.EUR)).eq("20,0 ct/kWh");
expect(fmt.fmtPricePerKWh(0.2, CURRENCY.EUR, true)).eq("20,0 ct"); expect(fmt.fmtPricePerKWh(0.2, CURRENCY.EUR, true)).eq("20,0 ct");
expect(fmt.fmtPricePerKWh(0.234, CURRENCY.USD)).eq("23,4 ¢/kWh"); expect(fmt.fmtPricePerKWh(0.234, CURRENCY.USD)).eq("23,4 ¢/kWh");
expect(fmt.fmtPricePerKWh(1234, CURRENCY.SEK)).eq("123.400,0 öre/kWh"); expect(fmt.fmtPricePerKWh(1234, CURRENCY.SEK)).eq("123.400,0 öre/kWh");
expect(fmt.fmtPricePerKWh(0.2, CURRENCY.EUR, false, false)).eq("20,0"); expect(fmt.fmtPricePerKWh(0.2, CURRENCY.EUR, false, false)).eq("20,0");
expect(fmt.fmtPricePerKWh(0.123, CURRENCY.CHF)).eq("12,3 rp/kWh"); expect(fmt.fmtPricePerKWh(0.123, CURRENCY.CHF)).eq("12,3 rp/kWh");
}); });
}); });
describe("pricePerKWhUnit", () => { describe("pricePerKWhUnit", () => {
test("should return correct unit", () => { test("should return correct unit", () => {
expect(fmt.pricePerKWhUnit(CURRENCY.EUR)).eq("ct/kWh"); expect(fmt.pricePerKWhUnit(CURRENCY.EUR)).eq("ct/kWh");
expect(fmt.pricePerKWhUnit(CURRENCY.EUR, true)).eq("ct"); expect(fmt.pricePerKWhUnit(CURRENCY.EUR, true)).eq("ct");
expect(fmt.pricePerKWhUnit(CURRENCY.USD)).eq("¢/kWh"); expect(fmt.pricePerKWhUnit(CURRENCY.USD)).eq("¢/kWh");
expect(fmt.pricePerKWhUnit(CURRENCY.SEK)).eq("öre/kWh"); expect(fmt.pricePerKWhUnit(CURRENCY.SEK)).eq("öre/kWh");
expect(fmt.pricePerKWhUnit(CURRENCY.SEK, true)).eq("öre"); expect(fmt.pricePerKWhUnit(CURRENCY.SEK, true)).eq("öre");
expect(fmt.pricePerKWhUnit(CURRENCY.CHF)).eq("rp/kWh"); expect(fmt.pricePerKWhUnit(CURRENCY.CHF)).eq("rp/kWh");
}); });
}); });
describe("fmtDuration", () => { describe("fmtDuration", () => {
test("should format zero duration", () => { test("should format zero duration", () => {
expect(fmt.fmtDuration(0)).eq("—"); expect(fmt.fmtDuration(0)).eq("—");
expect(fmt.fmtDuration(-100)).eq("—"); expect(fmt.fmtDuration(-100)).eq("—");
}); });
test("should format seconds", () => { test("should format seconds", () => {
expect(fmt.fmtDuration(1)).eq("1\u202Fs"); expect(fmt.fmtDuration(1)).eq("1\u202Fs");
expect(fmt.fmtDuration(59)).eq("59\u202Fs"); expect(fmt.fmtDuration(59)).eq("59\u202Fs");
expect(fmt.fmtDuration(59, false)).eq("59"); expect(fmt.fmtDuration(59, false)).eq("59");
expect(fmt.fmtDuration(59, true, "m")).eq("0:59\u202Fm"); expect(fmt.fmtDuration(59, true, "m")).eq("0:59\u202Fm");
expect(fmt.fmtDuration(59, true, "h")).eq("0:00\u202Fh"); expect(fmt.fmtDuration(59, true, "h")).eq("0:00\u202Fh");
}); });
test("should format minutes", () => { test("should format minutes", () => {
expect(fmt.fmtDuration(60)).eq("1:00\u202Fm"); expect(fmt.fmtDuration(60)).eq("1:00\u202Fm");
expect(fmt.fmtDuration(150)).eq("2:30\u202Fm"); expect(fmt.fmtDuration(150)).eq("2:30\u202Fm");
expect(fmt.fmtDuration(150, false)).eq("2:30"); expect(fmt.fmtDuration(150, false)).eq("2:30");
expect(fmt.fmtDuration(150, true, "h")).eq("0:02\u202Fh"); expect(fmt.fmtDuration(150, true, "h")).eq("0:02\u202Fh");
}); });
test("should format hours", () => { test("should format hours", () => {
expect(fmt.fmtDuration(60 * 60)).eq("1:00\u202Fh"); expect(fmt.fmtDuration(60 * 60)).eq("1:00\u202Fh");
expect(fmt.fmtDuration(60 * 60 * 2.5)).eq("2:30\u202Fh"); expect(fmt.fmtDuration(60 * 60 * 2.5)).eq("2:30\u202Fh");
expect(fmt.fmtDuration(60 * 60 * 2.5, false)).eq("2:30"); expect(fmt.fmtDuration(60 * 60 * 2.5, false)).eq("2:30");
}); });
test("should format internationalized", () => { test("should format internationalized", () => {
config.global.mocks["$i18n"].locale = "ar-EG"; config.global.mocks["$i18n"].locale = "ar-EG";
expect(fmt.fmtDuration(30, false)).eq("٣٠"); expect(fmt.fmtDuration(30, false)).eq("٣٠");
expect(fmt.fmtDuration(90, false)).eq("١:٣٠"); expect(fmt.fmtDuration(90, false)).eq("١:٣٠");
expect(fmt.fmtDuration(60 * 100, false)).eq("١:٤٠"); expect(fmt.fmtDuration(60 * 100, false)).eq("١:٤٠");
config.global.mocks["$i18n"].locale = "de-DE"; config.global.mocks["$i18n"].locale = "de-DE";
}); });
}); });
describe("getWeekdaysList", () => { describe("getWeekdaysList", () => {
test("should return the correct weekday-order", () => { test("should return the correct weekday-order", () => {
expect(fmt.getWeekdaysList("long")).toEqual([ expect(fmt.getWeekdaysList("long")).toEqual([
{ name: "Montag", value: 1 }, { name: "Montag", value: 1 },
{ name: "Dienstag", value: 2 }, { name: "Dienstag", value: 2 },
{ name: "Mittwoch", value: 3 }, { name: "Mittwoch", value: 3 },
{ name: "Donnerstag", value: 4 }, { name: "Donnerstag", value: 4 },
{ name: "Freitag", value: 5 }, { name: "Freitag", value: 5 },
{ name: "Samstag", value: 6 }, { name: "Samstag", value: 6 },
{ name: "Sonntag", value: 0 }, { name: "Sonntag", value: 0 },
]); ]);
}); });
}); });
describe("getShortenedWeekdaysLabel", () => { describe("getShortenedWeekdaysLabel", () => {
test("should format single days", () => { test("should format single days", () => {
expect(fmt.getShortenedWeekdaysLabel([0])).eq("So"); expect(fmt.getShortenedWeekdaysLabel([0])).eq("So");
expect(fmt.getShortenedWeekdaysLabel([0, 2, 4, 6])).eq("Di, Do, Sa, So"); expect(fmt.getShortenedWeekdaysLabel([0, 2, 4, 6])).eq("Di, Do, Sa, So");
expect(fmt.getShortenedWeekdaysLabel([6])).eq("Sa"); expect(fmt.getShortenedWeekdaysLabel([6])).eq("Sa");
expect(fmt.getShortenedWeekdaysLabel([3, 6])).eq("Mi, Sa"); expect(fmt.getShortenedWeekdaysLabel([3, 6])).eq("Mi, Sa");
}); });
test("should format ranges", () => { test("should format ranges", () => {
expect(fmt.getShortenedWeekdaysLabel([1, 2])).eq("Mo, Di"); expect(fmt.getShortenedWeekdaysLabel([1, 2])).eq("Mo, Di");
expect(fmt.getShortenedWeekdaysLabel([0, 1, 2, 3, 4, 5, 6])).eq("Mo – So"); expect(fmt.getShortenedWeekdaysLabel([0, 1, 2, 3, 4, 5, 6])).eq("Mo – So");
expect(fmt.getShortenedWeekdaysLabel([0, 1, 3, 4, 5])).eq("Mo, Mi – Fr, So"); expect(fmt.getShortenedWeekdaysLabel([0, 1, 3, 4, 5])).eq("Mo, Mi – Fr, So");
}); });
test("should format single days and ranges", () => { test("should format single days and ranges", () => {
expect(fmt.getShortenedWeekdaysLabel([0, 1, 3, 5, 6])).eq("Mo, Mi, Fr – So"); expect(fmt.getShortenedWeekdaysLabel([0, 1, 3, 5, 6])).eq("Mo, Mi, Fr – So");
expect(fmt.getShortenedWeekdaysLabel([0, 2, 3, 5, 6])).eq("Di, Mi, Fr – So"); expect(fmt.getShortenedWeekdaysLabel([0, 2, 3, 5, 6])).eq("Di, Mi, Fr – So");
}); });
}); });
describe("fmtPercentage", () => { describe("fmtPercentage", () => {
test("should format percentage", () => { test("should format percentage", () => {
expect(fmt.fmtPercentage(0)).eq("0 %"); expect(fmt.fmtPercentage(0)).eq("0 %");
expect(fmt.fmtPercentage(100)).eq("100 %"); expect(fmt.fmtPercentage(100)).eq("100 %");
expect(fmt.fmtPercentage(100, 1)).eq("100,0 %"); expect(fmt.fmtPercentage(100, 1)).eq("100,0 %");
}); });
test("should force signed", () => { test("should force signed", () => {
expect(fmt.fmtPercentage(100, 0, true)).eq("+100 %"); expect(fmt.fmtPercentage(100, 0, true)).eq("+100 %");
expect(fmt.fmtPercentage(100, 0, false)).eq("100 %"); expect(fmt.fmtPercentage(100, 0, false)).eq("100 %");
expect(fmt.fmtPercentage(-100, 0, true)).eq("-100 %"); expect(fmt.fmtPercentage(-100, 0, true)).eq("-100 %");
expect(fmt.fmtPercentage(-100, 0, false)).eq("-100 %"); expect(fmt.fmtPercentage(-100, 0, false)).eq("-100 %");
}); });
}); });
const testDate = new Date(2023, 0, 15, 15, 30); // Jan 15, 2023, 15:30 (3:30 PM) const testDate = new Date(2023, 0, 15, 15, 30); // Jan 15, 2023, 15:30 (3:30 PM)
describe("12h/24h time format", () => { describe("12h/24h time format", () => {
test("12h format", () => { test("12h format", () => {
is12hSpy.mockReturnValue(true); is12hSpy.mockReturnValue(true);
expect(fmt.fmtHourMinute(testDate)).toBe("3:30 PM"); expect(fmt.fmtHourMinute(testDate)).toBe("3:30 PM");
expect(fmt.fmtFullDateTime(testDate, false)).toBe("So., 15. Jan., 3:30 PM"); expect(fmt.fmtFullDateTime(testDate, false)).toBe("So., 15. Jan., 3:30 PM");
expect(fmt.fmtWeekdayTime(testDate)).toBe("So. 3:30 PM"); expect(fmt.fmtWeekdayTime(testDate)).toBe("So. 3:30 PM");
expect(fmt.fmtAbsoluteDate(testDate)).toBe("So 3:30 PM"); expect(fmt.fmtAbsoluteDate(testDate)).toBe("So 3:30 PM");
}); });
test("24h format", () => { test("24h format", () => {
is12hSpy.mockReturnValue(false); is12hSpy.mockReturnValue(false);
expect(fmt.fmtHourMinute(testDate)).toBe("15:30"); expect(fmt.fmtHourMinute(testDate)).toBe("15:30");
expect(fmt.fmtFullDateTime(testDate, false)).toBe("So., 15. Jan., 15:30"); expect(fmt.fmtFullDateTime(testDate, false)).toBe("So., 15. Jan., 15:30");
expect(fmt.fmtWeekdayTime(testDate)).toBe("So., 15:30"); expect(fmt.fmtWeekdayTime(testDate)).toBe("So., 15:30");
expect(fmt.fmtAbsoluteDate(testDate)).toBe("So 15:30"); expect(fmt.fmtAbsoluteDate(testDate)).toBe("So 15:30");
}); });
}); });

View file

@ -3,442 +3,440 @@ import { is12hFormat } from "@/units";
import { CURRENCY } from "../types/evcc"; import { CURRENCY } from "../types/evcc";
const CURRENCY_SYMBOLS: Record<CURRENCY, string> = { const CURRENCY_SYMBOLS: Record<CURRENCY, string> = {
AUD: "$", AUD: "$",
BGN: "лв", BGN: "лв",
BRL: "R$", BRL: "R$",
CAD: "$", CAD: "$",
CHF: "Fr.", CHF: "Fr.",
CNY: "¥", CNY: "¥",
EUR: "€", EUR: "€",
GBP: "£", GBP: "£",
ILS: "₪", ILS: "₪",
NZD: "$", NZD: "$",
PLN: "zł", PLN: "zł",
USD: "$", USD: "$",
DKK: "kr", DKK: "kr",
SEK: "kr", SEK: "kr",
}; };
// list of currencies where energy price should be displayed in subunits (factor 100) // list of currencies where energy price should be displayed in subunits (factor 100)
const ENERGY_PRICE_IN_SUBUNIT: Record<CURRENCY, string> = { const ENERGY_PRICE_IN_SUBUNIT: Record<CURRENCY, string> = {
AUD: "c", // Australian cent AUD: "c", // Australian cent
BGN: "st", // Bulgarian stotinka BGN: "st", // Bulgarian stotinka
BRL: "¢", // Brazilian centavo BRL: "¢", // Brazilian centavo
CAD: "¢", // Canadian cent CAD: "¢", // Canadian cent
CHF: "rp", // Swiss Rappen CHF: "rp", // Swiss Rappen
CNY: "f", // Chinese fen CNY: "f", // Chinese fen
EUR: "ct", // Euro cent EUR: "ct", // Euro cent
GBP: "p", // GB pence GBP: "p", // GB pence
ILS: "ag", // Israeli agora ILS: "ag", // Israeli agora
NZD: "c", // New Zealand cent NZD: "c", // New Zealand cent
PLN: "gr", // Polish grosz PLN: "gr", // Polish grosz
USD: "¢", // US cent USD: "¢", // US cent
DKK: "øre", // Danish øre DKK: "øre", // Danish øre
SEK: "öre", // Swedish öre SEK: "öre", // Swedish öre
}; };
export enum POWER_UNIT { export enum POWER_UNIT {
W = "W", W = "W",
KW = "kW", KW = "kW",
MW = "MW", MW = "MW",
AUTO = "", AUTO = "",
} }
export default defineComponent({ export default defineComponent({
data() { data() {
return { return {
POWER_UNIT, POWER_UNIT,
fmtLimit: 100, fmtLimit: 100,
fmtDigits: 1, fmtDigits: 1,
}; };
}, },
methods: { methods: {
round(num: number, precision: number) { round(num: number, precision: number) {
const base = 10 ** precision; const base = 10 ** precision;
return (Math.round(num * base) / base).toFixed(precision); return (Math.round(num * base) / base).toFixed(precision);
}, },
fmtW(watt = 0, format = POWER_UNIT.KW, withUnit = true, digits?: number) { fmtW(watt = 0, format = POWER_UNIT.KW, withUnit = true, digits?: number) {
let unit = format; let unit = format;
let d = digits; let d = digits;
if (POWER_UNIT.AUTO === unit) { if (POWER_UNIT.AUTO === unit) {
if (watt >= 10_000_000) { if (watt >= 10_000_000) {
unit = POWER_UNIT.MW; unit = POWER_UNIT.MW;
} else if (watt >= 1000 || 0 === watt) { } else if (watt >= 1000 || 0 === watt) {
unit = POWER_UNIT.KW; unit = POWER_UNIT.KW;
} else { } else {
unit = POWER_UNIT.W; unit = POWER_UNIT.W;
} }
} }
let value = watt; let value = watt;
if (POWER_UNIT.KW === unit) { if (POWER_UNIT.KW === unit) {
value = watt / 1000; value = watt / 1000;
} else if (POWER_UNIT.MW === unit) { } else if (POWER_UNIT.MW === unit) {
value = watt / 1_000_000; value = watt / 1_000_000;
} }
if (d === undefined) { if (d === undefined) {
d = d =
POWER_UNIT.KW === unit || POWER_UNIT.KW === unit || POWER_UNIT.MW === unit || (POWER_UNIT.W !== unit && 0 === watt)
POWER_UNIT.MW === unit || ? 1
(POWER_UNIT.W !== unit && 0 === watt) : 0;
? 1 }
: 0; return `${new Intl.NumberFormat(this.$i18n?.locale, {
} style: "decimal",
return `${new Intl.NumberFormat(this.$i18n?.locale, { minimumFractionDigits: d,
style: "decimal", maximumFractionDigits: d,
minimumFractionDigits: d, }).format(value)}${withUnit ? ` ${unit}` : ""}`;
maximumFractionDigits: d, },
}).format(value)}${withUnit ? ` ${unit}` : ""}`; fmtWh(watt: number, format = POWER_UNIT.KW, withUnit = true, digits?: number) {
}, return this.fmtW(watt, format, withUnit, digits) + (withUnit ? "h" : "");
fmtWh(watt: number, format = POWER_UNIT.KW, withUnit = true, digits?: number) { },
return this.fmtW(watt, format, withUnit, digits) + (withUnit ? "h" : ""); fmtNumber(number: number, decimals: number, unit?: string) {
}, const style = unit ? "unit" : "decimal";
fmtNumber(number: number, decimals: number, unit?: string) { return new Intl.NumberFormat(this.$i18n?.locale, {
const style = unit ? "unit" : "decimal"; style,
return new Intl.NumberFormat(this.$i18n?.locale, { unit,
style, minimumFractionDigits: decimals,
unit, maximumFractionDigits: decimals,
minimumFractionDigits: decimals, }).format(number);
maximumFractionDigits: decimals, },
}).format(number); fmtGrams(gramms: number, withUnit = true) {
}, let unit = "gram";
fmtGrams(gramms: number, withUnit = true) { let value = gramms;
let unit = "gram"; if (gramms >= 1000) {
let value = gramms; unit = "kilogram";
if (gramms >= 1000) { value = gramms / 1000;
unit = "kilogram"; }
value = gramms / 1000; return new Intl.NumberFormat(this.$i18n?.locale, {
} style: withUnit ? "unit" : "decimal",
return new Intl.NumberFormat(this.$i18n?.locale, { unit,
style: withUnit ? "unit" : "decimal", minimumFractionDigits: 0,
unit, maximumFractionDigits: 0,
minimumFractionDigits: 0, }).format(value);
maximumFractionDigits: 0, },
}).format(value); fmtCo2Short(gramms = 0) {
}, return `${this.fmtNumber(gramms, 0)} g`;
fmtCo2Short(gramms = 0) { },
return `${this.fmtNumber(gramms, 0)} g`; fmtCo2Medium(gramms = 0) {
}, return `${this.fmtNumber(gramms, 0)} g/kWh`;
fmtCo2Medium(gramms = 0) { },
return `${this.fmtNumber(gramms, 0)} g/kWh`; fmtCo2Long(gramms = 0) {
}, return `${this.fmtNumber(gramms, 0)} gCO₂e/kWh`;
fmtCo2Long(gramms = 0) { },
return `${this.fmtNumber(gramms, 0)} gCO₂e/kWh`; fmtNumberToLocale(val: number, pad = 0) {
}, return val.toLocaleString(this.$i18n?.locale).padStart(pad, "0");
fmtNumberToLocale(val: number, pad = 0) { },
return val.toLocaleString(this.$i18n?.locale).padStart(pad, "0"); fmtDurationToTime(date: Date) {
}, const diff = date.getTime() - new Date().getTime();
fmtDurationToTime(date: Date) { return this.fmtDuration(diff / 1000, true, "h");
const diff = date.getTime() - new Date().getTime(); },
return this.fmtDuration(diff / 1000, true, "h"); fmtDurationNs(duration = 0, withUnit = true, minUnit = "s") {
}, return this.fmtDuration(duration / 1e9, withUnit, minUnit);
fmtDurationNs(duration = 0, withUnit = true, minUnit = "s") { },
return this.fmtDuration(duration / 1e9, withUnit, minUnit); fmtDuration(duration = 0, withUnit = true, minUnit = "s") {
}, if (duration <= 0) {
fmtDuration(duration = 0, withUnit = true, minUnit = "s") { return "—";
if (duration <= 0) { }
return "—"; const roundedDuration = Math.round(duration);
} const seconds = roundedDuration % 60;
const roundedDuration = Math.round(duration); const minutes = Math.floor(roundedDuration / 60) % 60;
const seconds = roundedDuration % 60; const hours = Math.floor(roundedDuration / 3600);
const minutes = Math.floor(roundedDuration / 60) % 60; let result = "";
const hours = Math.floor(roundedDuration / 3600); let unit = "";
let result = ""; if (hours >= 1 || minUnit === "h") {
let unit = ""; result = `${this.fmtNumberToLocale(hours)}:${this.fmtNumberToLocale(minutes, 2)}`;
if (hours >= 1 || minUnit === "h") { unit = "h";
result = `${this.fmtNumberToLocale(hours)}:${this.fmtNumberToLocale(minutes, 2)}`; } else if (minutes >= 1 || minUnit === "m") {
unit = "h"; result = `${this.fmtNumberToLocale(minutes)}:${this.fmtNumberToLocale(seconds, 2)}`;
} else if (minutes >= 1 || minUnit === "m") { unit = "m";
result = `${this.fmtNumberToLocale(minutes)}:${this.fmtNumberToLocale(seconds, 2)}`; } else {
unit = "m"; result = `${this.fmtNumberToLocale(seconds)}`;
} else { unit = "s";
result = `${this.fmtNumberToLocale(seconds)}`; }
unit = "s"; if (withUnit) {
} result += `\u202F${unit}`;
if (withUnit) { }
result += `\u202F${unit}`; return result;
} },
return result; fmtDurationLong(seconds: number) {
}, // @ts-expect-error - Intl.DurationFormat is a new API not yet in TS types, see https://github.com/microsoft/TypeScript/issues/60608
fmtDurationLong(seconds: number) { if (!Intl.DurationFormat) {
// @ts-expect-error - Intl.DurationFormat is a new API not yet in TS types, see https://github.com/microsoft/TypeScript/issues/60608 // old browser fallback
if (!Intl.DurationFormat) { return this.fmtDuration(seconds);
// old browser fallback }
return this.fmtDuration(seconds); const hours = Math.floor(seconds / 3600);
} const minutes = Math.floor((seconds % 3600) / 60);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
// @ts-expect-error - Intl.DurationFormat is a new API not yet in TS types, see https://github.com/microsoft/TypeScript/issues/60608 // @ts-expect-error - Intl.DurationFormat is a new API not yet in TS types, see https://github.com/microsoft/TypeScript/issues/60608
const formatter = new Intl.DurationFormat(this.$i18n?.locale, { style: "long" }); const formatter = new Intl.DurationFormat(this.$i18n?.locale, { style: "long" });
return formatter.format({ minutes, hours }); return formatter.format({ minutes, hours });
}, },
fmtDayString(date: Date) { fmtDayString(date: Date) {
const YY = `${date.getFullYear()}`; const YY = `${date.getFullYear()}`;
const MM = `${date.getMonth() + 1}`.padStart(2, "0"); const MM = `${date.getMonth() + 1}`.padStart(2, "0");
const DD = `${date.getDate()}`.padStart(2, "0"); const DD = `${date.getDate()}`.padStart(2, "0");
return `${YY}-${MM}-${DD}`; return `${YY}-${MM}-${DD}`;
}, },
fmtTimeString(date: Date) { fmtTimeString(date: Date) {
const HH = `${date.getHours()}`.padStart(2, "0"); const HH = `${date.getHours()}`.padStart(2, "0");
const mm = `${date.getMinutes()}`.padStart(2, "0"); const mm = `${date.getMinutes()}`.padStart(2, "0");
return `${HH}:${mm}`; return `${HH}:${mm}`;
}, },
isToday(date: Date) { isToday(date: Date) {
const today = new Date(); const today = new Date();
return today.toDateString() === date.toDateString(); return today.toDateString() === date.toDateString();
}, },
isTomorrow(date: Date) { isTomorrow(date: Date) {
const tomorrow = new Date(); const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setDate(tomorrow.getDate() + 1);
return tomorrow.toDateString() === date.toDateString(); return tomorrow.toDateString() === date.toDateString();
}, },
weekdayPrefix(date: Date) { weekdayPrefix(date: Date) {
if (this.isToday(date)) { if (this.isToday(date)) {
return ""; return "";
} }
if (this.isTomorrow(date)) { if (this.isTomorrow(date)) {
try { try {
const rtf = new Intl.RelativeTimeFormat(this.$i18n?.locale, { const rtf = new Intl.RelativeTimeFormat(this.$i18n?.locale, {
numeric: "auto", numeric: "auto",
}); });
return rtf.formatToParts(1, "day")[0].value; return rtf.formatToParts(1, "day")[0].value;
} catch (e) { } catch (e) {
console.warn("weekdayPrefix: Intl.RelativeTimeFormat not supported", e); console.warn("weekdayPrefix: Intl.RelativeTimeFormat not supported", e);
return "tomorrow"; return "tomorrow";
} }
} }
return new Intl.DateTimeFormat(this.$i18n?.locale, { return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: "short", weekday: "short",
}).format(date); }).format(date);
}, },
hourShort(date: Date) { hourShort(date: Date) {
const locale = this.$i18n?.locale; const locale = this.$i18n?.locale;
// special: use shorter german format // special: use shorter german format
if (locale === "de") return date.getHours(); if (locale === "de") return date.getHours();
return new Intl.DateTimeFormat(locale, { return new Intl.DateTimeFormat(locale, {
hour: "numeric", hour: "numeric",
hour12: is12hFormat(), hour12: is12hFormat(),
}).format(date); }).format(date);
}, },
weekdayShort(date: Date) { weekdayShort(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, { return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: "short", weekday: "short",
}).format(date); }).format(date);
}, },
fmtAbsoluteDate(date: Date) { fmtAbsoluteDate(date: Date) {
const weekday = this.weekdayPrefix(date); const weekday = this.weekdayPrefix(date);
const hour = new Intl.DateTimeFormat(this.$i18n?.locale, { const hour = new Intl.DateTimeFormat(this.$i18n?.locale, {
hour: "numeric", hour: "numeric",
minute: "numeric", minute: "numeric",
hour12: is12hFormat(), hour12: is12hFormat(),
}).format(date); }).format(date);
return `${weekday} ${hour}`.trim(); return `${weekday} ${hour}`.trim();
}, },
fmtHourMinute(date: Date) { fmtHourMinute(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, { return new Intl.DateTimeFormat(this.$i18n?.locale, {
hour: "numeric", hour: "numeric",
minute: "numeric", minute: "numeric",
hour12: is12hFormat(), hour12: is12hFormat(),
}).format(date); }).format(date);
}, },
fmtFullDateTime(date: Date, short: boolean) { fmtFullDateTime(date: Date, short: boolean) {
return new Intl.DateTimeFormat(this.$i18n?.locale, { return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: short ? undefined : "short", weekday: short ? undefined : "short",
month: short ? "numeric" : "short", month: short ? "numeric" : "short",
day: "numeric", day: "numeric",
hour: "numeric", hour: "numeric",
minute: "numeric", minute: "numeric",
hour12: is12hFormat(), hour12: is12hFormat(),
}).format(date); }).format(date);
}, },
fmtWeekdayTime(date: Date) { fmtWeekdayTime(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, { return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: "short", weekday: "short",
hour: "numeric", hour: "numeric",
minute: "numeric", minute: "numeric",
hour12: is12hFormat(), hour12: is12hFormat(),
}).format(date); }).format(date);
}, },
fmtMonthYear(date: Date) { fmtMonthYear(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, { return new Intl.DateTimeFormat(this.$i18n?.locale, {
month: "long", month: "long",
year: "numeric", year: "numeric",
}).format(date); }).format(date);
}, },
fmtMonth(date: Date, short: boolean) { fmtMonth(date: Date, short: boolean) {
return new Intl.DateTimeFormat(this.$i18n?.locale, { return new Intl.DateTimeFormat(this.$i18n?.locale, {
month: short ? "short" : "long", month: short ? "short" : "long",
}).format(date); }).format(date);
}, },
fmtDayMonth(date: Date) { fmtDayMonth(date: Date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, { return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: "short", weekday: "short",
day: "numeric", day: "numeric",
month: "short", month: "short",
}).format(date); }).format(date);
}, },
fmtDurationUnit(value: number, unit = "second") { fmtDurationUnit(value: number, unit = "second") {
return new Intl.NumberFormat(this.$i18n?.locale, { return new Intl.NumberFormat(this.$i18n?.locale, {
style: "unit", style: "unit",
unit, unit,
unitDisplay: "long", unitDisplay: "long",
}) })
.formatToParts(value) .formatToParts(value)
.find((part) => part.type === "unit")?.value; .find((part) => part.type === "unit")?.value;
}, },
fmtMoney(amout = 0, currency = CURRENCY.EUR, decimals = true, withSymbol = false) { fmtMoney(amout = 0, currency = CURRENCY.EUR, decimals = true, withSymbol = false) {
const currencyDisplay = withSymbol ? "narrowSymbol" : "code"; const currencyDisplay = withSymbol ? "narrowSymbol" : "code";
const digits = decimals ? undefined : 0; const digits = decimals ? undefined : 0;
const result = new Intl.NumberFormat(this.$i18n?.locale, { const result = new Intl.NumberFormat(this.$i18n?.locale, {
style: "currency", style: "currency",
currency, currency,
currencyDisplay, currencyDisplay,
minimumFractionDigits: digits, minimumFractionDigits: digits,
maximumFractionDigits: digits, maximumFractionDigits: digits,
}).format(amout); }).format(amout);
return withSymbol ? result : result.replace(currency, "").trim(); return withSymbol ? result : result.replace(currency, "").trim();
}, },
fmtCurrencySymbol(currency = CURRENCY.EUR) { fmtCurrencySymbol(currency = CURRENCY.EUR) {
return CURRENCY_SYMBOLS[currency] || currency; return CURRENCY_SYMBOLS[currency] || currency;
}, },
fmtPricePerKWh(amout = 0, currency = CURRENCY.EUR, short = false, withUnit = true) { fmtPricePerKWh(amout = 0, currency = CURRENCY.EUR, short = false, withUnit = true) {
let value = amout; let value = amout;
let minimumFractionDigits = 1; let minimumFractionDigits = 1;
let maximumFractionDigits = 3; let maximumFractionDigits = 3;
if (ENERGY_PRICE_IN_SUBUNIT[currency]) { if (ENERGY_PRICE_IN_SUBUNIT[currency]) {
value *= 100; value *= 100;
minimumFractionDigits = 1; minimumFractionDigits = 1;
maximumFractionDigits = 1; maximumFractionDigits = 1;
} }
const price = new Intl.NumberFormat(this.$i18n?.locale, { const price = new Intl.NumberFormat(this.$i18n?.locale, {
style: "decimal", style: "decimal",
minimumFractionDigits, minimumFractionDigits,
maximumFractionDigits, maximumFractionDigits,
}).format(value); }).format(value);
if (withUnit) { if (withUnit) {
return `${price} ${this.pricePerKWhUnit(currency, short)}`; return `${price} ${this.pricePerKWhUnit(currency, short)}`;
} }
return price; return price;
}, },
timezone() { timezone() {
return Intl?.DateTimeFormat?.().resolvedOptions?.().timeZone || "UTC"; return Intl?.DateTimeFormat?.().resolvedOptions?.().timeZone || "UTC";
}, },
pricePerKWhUnit(currency = CURRENCY.EUR, short = false) { pricePerKWhUnit(currency = CURRENCY.EUR, short = false) {
const unit = ENERGY_PRICE_IN_SUBUNIT[currency] || currency; const unit = ENERGY_PRICE_IN_SUBUNIT[currency] || currency;
return `${unit}${short ? "" : "/kWh"}`; return `${unit}${short ? "" : "/kWh"}`;
}, },
fmtTimeAgo(elapsed: number) { fmtTimeAgo(elapsed: number) {
const units = { const units = {
day: 24 * 60 * 60 * 1000, day: 24 * 60 * 60 * 1000,
hour: 60 * 60 * 1000, hour: 60 * 60 * 1000,
minute: 60 * 1000, minute: 60 * 1000,
second: 1000, second: 1000,
}; };
// "Math.abs" accounts for both "past" & "future" scenarios // "Math.abs" accounts for both "past" & "future" scenarios
for (const u in units) { for (const u in units) {
const unitKey = u as keyof typeof units; const unitKey = u as keyof typeof units;
if (Math.abs(elapsed) > units[unitKey] || u == "second") { if (Math.abs(elapsed) > units[unitKey] || u == "second") {
try { try {
const rtf = new Intl.RelativeTimeFormat(this.$i18n?.locale, { const rtf = new Intl.RelativeTimeFormat(this.$i18n?.locale, {
numeric: "auto", numeric: "auto",
}); });
return rtf.format(Math.round(elapsed / units[unitKey]), unitKey); return rtf.format(Math.round(elapsed / units[unitKey]), unitKey);
} catch (e) { } catch (e) {
console.warn("fmtTimeAgo: Intl.RelativeTimeFormat not supported", e); console.warn("fmtTimeAgo: Intl.RelativeTimeFormat not supported", e);
return `${elapsed} ${u}s ago`; return `${elapsed} ${u}s ago`;
} }
} }
} }
return ""; return "";
}, },
fmtSocOption(soc: number, rangePerSoc?: number, distanceUnit?: string, heating?: boolean) { fmtSocOption(soc: number, rangePerSoc?: number, distanceUnit?: string, heating?: boolean) {
let result = heating ? this.fmtTemperature(soc) : `${this.fmtPercentage(soc)}`; let result = heating ? this.fmtTemperature(soc) : `${this.fmtPercentage(soc)}`;
if (rangePerSoc && distanceUnit) { if (rangePerSoc && distanceUnit) {
const range = soc * rangePerSoc; const range = soc * rangePerSoc;
result += ` (${this.fmtNumber(range, 0)} ${distanceUnit})`; result += ` (${this.fmtNumber(range, 0)} ${distanceUnit})`;
} }
return result; return result;
}, },
fmtPercentage(value: number, digits = 0, forceSign = false) { fmtPercentage(value: number, digits = 0, forceSign = false) {
const sign = forceSign && value > 0 ? "+" : ""; const sign = forceSign && value > 0 ? "+" : "";
return `${sign}${new Intl.NumberFormat(this.$i18n?.locale, { return `${sign}${new Intl.NumberFormat(this.$i18n?.locale, {
style: "percent", style: "percent",
minimumFractionDigits: digits, minimumFractionDigits: digits,
maximumFractionDigits: digits, maximumFractionDigits: digits,
}).format(value / 100)}`; }).format(value / 100)}`;
}, },
hasLeadingPercentageSign() { hasLeadingPercentageSign() {
return ["tr", "ar"].includes(this.$i18n?.locale); return ["tr", "ar"].includes(this.$i18n?.locale);
}, },
fmtTemperature(value: number) { fmtTemperature(value: number) {
// TODO: handle fahrenheit // TODO: handle fahrenheit
return this.fmtNumber(value, 1, "celsius"); return this.fmtNumber(value, 1, "celsius");
}, },
getWeekdaysList( getWeekdaysList(
weekdayFormat: Intl.DateTimeFormatOptions["weekday"] weekdayFormat: Intl.DateTimeFormatOptions["weekday"]
): { name: string; value: number }[] { ): { name: string; value: number }[] {
const { format } = new Intl.DateTimeFormat(this.$i18n?.locale, { const { format } = new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: weekdayFormat, weekday: weekdayFormat,
}); });
const mondayToSaturday = [7, 8, 9, 10, 11, 12].map((day, index) => { const mondayToSaturday = [7, 8, 9, 10, 11, 12].map((day, index) => {
return { name: format(new Date(Date.UTC(2021, 5, day))), value: index + 1 }; return { name: format(new Date(Date.UTC(2021, 5, day))), value: index + 1 };
}); });
const sunday = { name: format(new Date(Date.UTC(2021, 5, 6))), value: 0 }; const sunday = { name: format(new Date(Date.UTC(2021, 5, 6))), value: 0 };
return [...mondayToSaturday, sunday]; return [...mondayToSaturday, sunday];
}, },
getShortenedWeekdaysLabel(selectedWeekdays: number[]): string { getShortenedWeekdaysLabel(selectedWeekdays: number[]): string {
if (0 === selectedWeekdays.length) { if (0 === selectedWeekdays.length) {
return "–"; return "–";
} }
const weekdays = this.getWeekdaysList("short"); const weekdays = this.getWeekdaysList("short");
let label = ""; let label = "";
// the week in the input-parameter starts with 0 for sunday and ends with 6 for saturday // the week in the input-parameter starts with 0 for sunday and ends with 6 for saturday
// this algorithms works only if the week starts with 1 for monday and ends with 7 for sunday because // this algorithms works only if the week starts with 1 for monday and ends with 7 for sunday because
// then we are able to count from 1 to 7 by incrementing the number // then we are able to count from 1 to 7 by incrementing the number
// so we have to transform the input accordingly // so we have to transform the input accordingly
const selectedWeekdaysTransformed = selectedWeekdays.map(function (dayIndex) { const selectedWeekdaysTransformed = selectedWeekdays.map(function (dayIndex) {
return 0 === dayIndex ? 7 : dayIndex; return 0 === dayIndex ? 7 : dayIndex;
}); });
function getWeekdayName(dayIndex: number) { function getWeekdayName(dayIndex: number) {
return weekdays.find((day) => day.value === (7 === dayIndex ? 0 : dayIndex))?.name; return weekdays.find((day) => day.value === (7 === dayIndex ? 0 : dayIndex))?.name;
} }
const maxWeekday = Math.max(...selectedWeekdaysTransformed); const maxWeekday = Math.max(...selectedWeekdaysTransformed);
for (let weekdayRangeStart = 1; weekdayRangeStart < 8; weekdayRangeStart++) { for (let weekdayRangeStart = 1; weekdayRangeStart < 8; weekdayRangeStart++) {
if (selectedWeekdaysTransformed.includes(weekdayRangeStart)) { if (selectedWeekdaysTransformed.includes(weekdayRangeStart)) {
label += getWeekdayName(weekdayRangeStart); label += getWeekdayName(weekdayRangeStart);
let weekdayRangeEnd = weekdayRangeStart; let weekdayRangeEnd = weekdayRangeStart;
while (selectedWeekdaysTransformed.includes(weekdayRangeEnd + 1)) { while (selectedWeekdaysTransformed.includes(weekdayRangeEnd + 1)) {
weekdayRangeEnd++; weekdayRangeEnd++;
} }
if (weekdayRangeEnd - weekdayRangeStart > 1) { if (weekdayRangeEnd - weekdayRangeStart > 1) {
// more than 2 consecutive weekdays selected // more than 2 consecutive weekdays selected
label += " – " + getWeekdayName(weekdayRangeEnd); label += " – " + getWeekdayName(weekdayRangeEnd);
weekdayRangeStart = weekdayRangeEnd; weekdayRangeStart = weekdayRangeEnd;
if (maxWeekday !== weekdayRangeEnd) { if (maxWeekday !== weekdayRangeEnd) {
label += ", "; label += ", ";
} }
} else if (weekdayRangeStart !== weekdayRangeEnd) { } else if (weekdayRangeStart !== weekdayRangeEnd) {
// exactly 2 consecutive weekdays selected // exactly 2 consecutive weekdays selected
label += ", "; label += ", ";
} else { } else {
// exactly 1 single day selected // exactly 1 single day selected
if (maxWeekday !== weekdayRangeEnd) { if (maxWeekday !== weekdayRangeEnd) {
label += ", "; label += ", ";
} }
} }
} }
} }
return label; return label;
}, },
}, },
}); });

View file

@ -2,26 +2,26 @@ import { ICON_SIZE } from "@/types/evcc";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
export default defineComponent({ export default defineComponent({
props: { props: {
size: { size: {
type: String as PropType<ICON_SIZE>, type: String as PropType<ICON_SIZE>,
validator(value: ICON_SIZE) { validator(value: ICON_SIZE) {
return Object.values(ICON_SIZE).includes(value); return Object.values(ICON_SIZE).includes(value);
}, },
default: ICON_SIZE.S, default: ICON_SIZE.S,
}, },
}, },
computed: { computed: {
svgStyle() { svgStyle() {
const sizes: Record<ICON_SIZE, string> = { const sizes: Record<ICON_SIZE, string> = {
xs: "16px", xs: "16px",
s: "24px", s: "24px",
m: "32px", m: "32px",
l: "48px", l: "48px",
xl: "64px", xl: "64px",
}; };
const size = sizes[this.size]; const size = sizes[this.size];
return { display: "block", width: size, height: size }; return { display: "block", width: size, height: size };
}, },
}, },
}); });

View file

@ -2,22 +2,22 @@ import { reactive } from "vue";
import api from "./api"; import api from "./api";
const restart = reactive({ const restart = reactive({
restartNeeded: false, restartNeeded: false,
restarting: false, restarting: false,
}); });
export async function performRestart() { export async function performRestart() {
try { try {
await api.post("/system/shutdown"); await api.post("/system/shutdown");
restart.restarting = true; restart.restarting = true;
} catch (e) { } catch (e) {
alert(`Unable to restart server. ${e}`); alert(`Unable to restart server. ${e}`);
} }
} }
export function restartComplete() { export function restartComplete() {
restart.restarting = false; restart.restarting = false;
restart.restartNeeded = false; restart.restartNeeded = false;
} }
export default restart; export default restart;

View file

@ -1,102 +1,102 @@
import { import {
createRouter, createRouter,
createWebHashHistory, createWebHashHistory,
type RouteLocationNormalizedGeneric, type RouteLocationNormalizedGeneric,
} from "vue-router"; } from "vue-router";
import Modal from "bootstrap/js/dist/modal"; import Modal from "bootstrap/js/dist/modal";
import { ensureCurrentLocaleMessages } from "./i18n.ts"; import { ensureCurrentLocaleMessages } from "./i18n.ts";
import { import {
openLoginModal, openLoginModal,
statusUnknown, statusUnknown,
updateAuthStatus, updateAuthStatus,
isLoggedIn, isLoggedIn,
isConfigured, isConfigured,
} from "./components/Auth/auth"; } from "./components/Auth/auth";
import type { VueI18nInstance } from "vue-i18n"; import type { VueI18nInstance } from "vue-i18n";
function hideAllModals() { function hideAllModals() {
[...document.querySelectorAll(".modal.show")].forEach((modal) => { [...document.querySelectorAll(".modal.show")].forEach((modal) => {
// skip unclosable modals // skip unclosable modals
if (modal.getAttribute("data-bs-backdrop") === "static") return; if (modal.getAttribute("data-bs-backdrop") === "static") return;
const modalInstance = Modal.getInstance(modal); const modalInstance = Modal.getInstance(modal);
if (modalInstance) { if (modalInstance) {
modalInstance.hide(); modalInstance.hide();
} }
}); });
} }
async function ensureAuth(to: RouteLocationNormalizedGeneric) { async function ensureAuth(to: RouteLocationNormalizedGeneric) {
await updateAuthStatus(); await updateAuthStatus();
if (!isConfigured()) { if (!isConfigured()) {
return false; return false;
} }
if (!isLoggedIn() && !statusUnknown()) { if (!isLoggedIn() && !statusUnknown()) {
openLoginModal(to.path); openLoginModal(to.path);
return false; return false;
} }
return true; return true;
} }
export default function setupRouter(i18n: VueI18nInstance) { export default function setupRouter(i18n: VueI18nInstance) {
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
routes: [ routes: [
{ {
path: "/", path: "/",
component: () => import("./views/Main.vue"), component: () => import("./views/Main.vue"),
props: (route) => { props: (route) => {
const { lp } = route.query; const { lp } = route.query;
return { return {
selectedLoadpointIndex: lp ? parseInt(lp as string, 10) - 1 : undefined, selectedLoadpointIndex: lp ? parseInt(lp as string, 10) - 1 : undefined,
}; };
}, },
}, },
{ {
path: "/config", path: "/config",
component: () => import("./views/Config.vue"), component: () => import("./views/Config.vue"),
beforeEnter: ensureAuth, beforeEnter: ensureAuth,
props: true, props: true,
}, },
{ {
path: "/sessions", path: "/sessions",
component: () => import("./views/Sessions.vue"), component: () => import("./views/Sessions.vue"),
props: (route) => { props: (route) => {
const { month, year, loadpoint, vehicle, period } = route.query; const { month, year, loadpoint, vehicle, period } = route.query;
return { return {
month: month ? parseInt(month as string, 10) : undefined, month: month ? parseInt(month as string, 10) : undefined,
year: year ? parseInt(year as string, 10) : undefined, year: year ? parseInt(year as string, 10) : undefined,
period, period,
loadpointFilter: loadpoint, loadpointFilter: loadpoint,
vehicleFilter: vehicle, vehicleFilter: vehicle,
}; };
}, },
}, },
{ {
path: "/energy", path: "/energy",
component: () => import("./views/Energy.vue"), component: () => import("./views/Energy.vue"),
props: true, props: true,
}, },
{ {
path: "/log", path: "/log",
component: () => import("./views/Log.vue"), component: () => import("./views/Log.vue"),
beforeEnter: ensureAuth, beforeEnter: ensureAuth,
props: (route) => { props: (route) => {
const { areas, level } = route.query; const { areas, level } = route.query;
return { return {
areas: typeof areas === "string" ? areas.split(",") : undefined, areas: typeof areas === "string" ? areas.split(",") : undefined,
level: typeof level === "string" ? level : undefined, level: typeof level === "string" ? level : undefined,
}; };
}, },
}, },
], ],
}); });
router.beforeEach(async () => { router.beforeEach(async () => {
await ensureCurrentLocaleMessages(i18n); await ensureCurrentLocaleMessages(i18n);
return true; return true;
}); });
router.afterEach(() => { router.afterEach(() => {
hideAllModals(); hideAllModals();
}); });
return router; return router;
} }

View file

@ -2,68 +2,68 @@ import { reactive } from "vue";
import type { State } from "./types/evcc"; import type { State } from "./types/evcc";
function setProperty(obj: object, props: string[], value: any) { function setProperty(obj: object, props: string[], value: any) {
const prop = props.shift(); const prop = props.shift();
// @ts-expect-error no-explicit-any // @ts-expect-error no-explicit-any
if (!obj[prop]) { if (!obj[prop]) {
// @ts-expect-error no-explicit-any // @ts-expect-error no-explicit-any
obj[prop] = {}; obj[prop] = {};
} }
if (!props.length) { if (!props.length) {
if (value && typeof value === "object" && !Array.isArray(value)) { if (value && typeof value === "object" && !Array.isArray(value)) {
// @ts-expect-error no-explicit-any // @ts-expect-error no-explicit-any
obj[prop] = { ...obj[prop], ...value }; obj[prop] = { ...obj[prop], ...value };
} else { } else {
// @ts-expect-error no-explicit-any // @ts-expect-error no-explicit-any
obj[prop] = value; obj[prop] = value;
} }
return; return;
} }
// @ts-expect-error no-explicit-any // @ts-expect-error no-explicit-any
setProperty(obj[prop], props, value); setProperty(obj[prop], props, value);
} }
const initialState: State = { const initialState: State = {
offline: false, offline: false,
// ensure array type // ensure array type
loadpoints: [], loadpoints: [],
vehicles: [], vehicles: [],
}; };
const state = reactive(initialState); const state = reactive(initialState);
const store = { const store = {
state, state,
offline(value: boolean) { offline(value: boolean) {
state.offline = value; state.offline = value;
}, },
// @ts-expect-error no-explicit-any // @ts-expect-error no-explicit-any
update(msg) { update(msg) {
Object.keys(msg).forEach(function (k) { Object.keys(msg).forEach(function (k) {
if (k === "log") { if (k === "log") {
window.app.raise(msg[k]); window.app.raise(msg[k]);
} else { } else {
setProperty(state, k.split("."), msg[k]); setProperty(state, k.split("."), msg[k]);
} }
}); });
}, },
reset() { reset() {
console.log("resetting state"); console.log("resetting state");
// reset to initial state // reset to initial state
Object.keys(initialState).forEach(function (k) { Object.keys(initialState).forEach(function (k) {
if (k === "offline") return; if (k === "offline") return;
// @ts-expect-error no-explicit-any // @ts-expect-error no-explicit-any
if (Array.isArray(initialState[k])) { if (Array.isArray(initialState[k])) {
// @ts-expect-error no-explicit-any // @ts-expect-error no-explicit-any
state[k] = []; state[k] = [];
} else { } else {
// @ts-expect-error no-explicit-any // @ts-expect-error no-explicit-any
state[k] = undefined; state[k] = undefined;
} }
}); });
}, },
}; };
export default store; export default store;

View file

@ -4,68 +4,68 @@ import { THEME } from "./types/evcc";
const darkModeMatcher = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)"); const darkModeMatcher = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
export function getThemePreference(): THEME { export function getThemePreference(): THEME {
const theme = settings.theme; const theme = settings.theme;
if (Object.values(THEME).includes(theme)) { if (Object.values(THEME).includes(theme)) {
return theme; return theme;
} }
return THEME.AUTO; return THEME.AUTO;
} }
export function setThemePreference(theme: THEME) { export function setThemePreference(theme: THEME) {
if (!Object.values(THEME).includes(theme)) { if (!Object.values(THEME).includes(theme)) {
return; return;
} }
settings.theme = theme; settings.theme = theme;
updateTheme(); updateTheme();
} }
function setMetaThemeColor(theme: Exclude<THEME, THEME.AUTO>) { function setMetaThemeColor(theme: Exclude<THEME, THEME.AUTO>) {
const themeColors = { light: "#f3f3f7", dark: "#020318" }; const themeColors = { light: "#f3f3f7", dark: "#020318" };
const $metaThemeColor = document.querySelector("meta[name=theme-color]"); const $metaThemeColor = document.querySelector("meta[name=theme-color]");
if ($metaThemeColor) { if ($metaThemeColor) {
$metaThemeColor.setAttribute("content", themeColors[theme]); $metaThemeColor.setAttribute("content", themeColors[theme]);
} }
} }
function getCurrentTheme() { function getCurrentTheme() {
let theme = getThemePreference(); let theme = getThemePreference();
if (theme === THEME.AUTO) { if (theme === THEME.AUTO) {
theme = darkModeMatcher?.matches ? THEME.DARK : THEME.LIGHT; theme = darkModeMatcher?.matches ? THEME.DARK : THEME.LIGHT;
} }
return theme; return theme;
} }
function updateTheme() { function updateTheme() {
const theme = getCurrentTheme(); const theme = getCurrentTheme();
// update iOS title bar color // update iOS title bar color
setMetaThemeColor(theme); setMetaThemeColor(theme);
// toggle the class on html root // toggle the class on html root
const $html = document.querySelector("html"); const $html = document.querySelector("html");
if ($html) { if ($html) {
$html.classList.add("no-transitions"); $html.classList.add("no-transitions");
$html.classList.toggle("dark", theme === THEME.DARK); $html.classList.toggle("dark", theme === THEME.DARK);
window.setTimeout(function () { window.setTimeout(function () {
$html.classList.remove("no-transitions"); $html.classList.remove("no-transitions");
}, 100); }, 100);
} }
} }
function updateMetaThemeForBackdrop() { function updateMetaThemeForBackdrop() {
const $backdrop = document.querySelector("[data-bs-backdrop=true][aria-modal=true]"); const $backdrop = document.querySelector("[data-bs-backdrop=true][aria-modal=true]");
// dark if there is a backdrop, otherwise use the current theme // dark if there is a backdrop, otherwise use the current theme
const theme = $backdrop ? THEME.DARK : getCurrentTheme(); const theme = $backdrop ? THEME.DARK : getCurrentTheme();
setMetaThemeColor(theme); setMetaThemeColor(theme);
} }
export function watchThemeChanges() { export function watchThemeChanges() {
if (darkModeMatcher && darkModeMatcher.addEventListener) { if (darkModeMatcher && darkModeMatcher.addEventListener) {
darkModeMatcher.addEventListener("change", updateTheme); darkModeMatcher.addEventListener("change", updateTheme);
} }
updateTheme(); updateTheme();
// listen for modal backdrops // listen for modal backdrops
document.addEventListener("shown.bs.modal", updateMetaThemeForBackdrop); document.addEventListener("shown.bs.modal", updateMetaThemeForBackdrop);
document.addEventListener("hidden.bs.modal", updateMetaThemeForBackdrop); document.addEventListener("hidden.bs.modal", updateMetaThemeForBackdrop);
} }

View file

@ -2,170 +2,170 @@ import type { StaticPlan, RepeatingPlan } from "../components/ChargingPlans/type
import type { ForecastSlot, SolarDetails } from "../components/Forecast/types"; import type { ForecastSlot, SolarDetails } from "../components/Forecast/types";
declare global { declare global {
interface Window { interface Window {
app: any; app: any;
evcc: { evcc: {
version: string; version: string;
commit: string; commit: string;
customCss: string; customCss: string;
}; };
} }
} }
export interface Auth { export interface Auth {
vehicles: VehicleLogins; vehicles: VehicleLogins;
} }
export type VehicleLogins = Record<string, { authenticated: boolean; uri: string }>; export type VehicleLogins = Record<string, { authenticated: boolean; uri: string }>;
export interface FatalError { export interface FatalError {
error: any; error: any;
class?: any; class?: any;
} }
export interface State { export interface State {
offline: boolean; offline: boolean;
startup?: boolean; startup?: boolean;
loadpoints: []; loadpoints: [];
forecast?: Forecast; forecast?: Forecast;
currency?: CURRENCY; currency?: CURRENCY;
fatal?: FatalError; fatal?: FatalError;
auth?: Auth; auth?: Auth;
vehicles: Vehicle[]; vehicles: Vehicle[];
} }
export interface LoadpointCompact { export interface LoadpointCompact {
icon: string; icon: string;
title: string; title: string;
charging: boolean; charging: boolean;
soc?: number; soc?: number;
power: number; power: number;
heating?: boolean; heating?: boolean;
chargePower: number; chargePower: number;
connected: boolean; connected: boolean;
index: number; index: number;
vehicleName: string; vehicleName: string;
chargerIcon?: string; chargerIcon?: string;
vehicleSoc: number; vehicleSoc: number;
chargerFeatureHeating: boolean; chargerFeatureHeating: boolean;
} }
export enum THEME { export enum THEME {
AUTO = "auto", AUTO = "auto",
LIGHT = "light", LIGHT = "light",
DARK = "dark", DARK = "dark",
} }
export enum CURRENCY { export enum CURRENCY {
AUD = "AUD", AUD = "AUD",
BGN = "BGN", BGN = "BGN",
BRL = "BRL", BRL = "BRL",
CAD = "CAD", CAD = "CAD",
CHF = "CHF", CHF = "CHF",
CNY = "CNY", CNY = "CNY",
EUR = "EUR", EUR = "EUR",
GBP = "GBP", GBP = "GBP",
ILS = "ILS", ILS = "ILS",
NZD = "NZD", NZD = "NZD",
PLN = "PLN", PLN = "PLN",
USD = "USD", USD = "USD",
DKK = "DKK", DKK = "DKK",
SEK = "SEK", SEK = "SEK",
} }
export enum ICON_SIZE { export enum ICON_SIZE {
XS = "xs", XS = "xs",
S = "s", S = "s",
M = "m", M = "m",
L = "l", L = "l",
XL = "xl", XL = "xl",
} }
export enum CHARGE_MODE { export enum CHARGE_MODE {
OFF = "off", OFF = "off",
NOW = "now", NOW = "now",
MINPV = "minpv", MINPV = "minpv",
PV = "pv", PV = "pv",
} }
export enum PHASES { export enum PHASES {
AUTO = 0, AUTO = 0,
ONE_PHASE = 1, ONE_PHASE = 1,
TWO_PHASES = 2, TWO_PHASES = 2,
THREE_PHASES = 3, THREE_PHASES = 3,
} }
export interface Sponsor { export interface Sponsor {
name: string; name: string;
expiresAt: string; expiresAt: string;
expiresSoon: boolean; expiresSoon: boolean;
} }
export interface Notification { export interface Notification {
message: string; message: string;
time: Date; time: Date;
level: string; level: string;
lp: number; lp: number;
count: number; count: number;
} }
export interface Battery { export interface Battery {
power: number; power: number;
soc: number; soc: number;
controllable: boolean; controllable: boolean;
capacity: number; // 0 when not specified capacity: number; // 0 when not specified
title?: string; title?: string;
} }
export interface Vehicle { export interface Vehicle {
name: string; name: string;
minSoc?: number; minSoc?: number;
limitSoc?: number; limitSoc?: number;
plan?: StaticPlan; plan?: StaticPlan;
repeatingPlans: RepeatingPlan[]; repeatingPlans: RepeatingPlan[];
title: string; title: string;
features?: string[]; features?: string[];
capacity?: number; capacity?: number;
icon?: string; icon?: string;
} }
export type Timeout = ReturnType<typeof setInterval> | null; export type Timeout = ReturnType<typeof setInterval> | null;
export interface Tariff { export interface Tariff {
rates: Rate[]; rates: Rate[];
lastUpdate: Date; lastUpdate: Date;
} }
export interface Rate { export interface Rate {
start: Date; start: Date;
end: Date; end: Date;
value: number; value: number;
} }
export interface Slot { export interface Slot {
day: string; day: string;
value?: number; value?: number;
startHour: number; startHour: number;
endHour: number; endHour: number;
charging: boolean; charging: boolean;
toLate?: boolean | null; toLate?: boolean | null;
warning?: boolean | null; warning?: boolean | null;
isTarget?: boolean | null; isTarget?: boolean | null;
selectable?: boolean | null; selectable?: boolean | null;
} }
export interface Forecast { export interface Forecast {
grid?: ForecastSlot[]; grid?: ForecastSlot[];
co2?: ForecastSlot[]; co2?: ForecastSlot[];
solar?: SolarDetails; solar?: SolarDetails;
planner?: ForecastSlot[]; planner?: ForecastSlot[];
} }
export interface SelectOption<T> { export interface SelectOption<T> {
name: string; name: string;
value: T; value: T;
count?: number; count?: number;
disabled?: boolean; disabled?: boolean;
} }
export type DeviceType = "charger" | "meter" | "vehicle"; export type DeviceType = "charger" | "meter" | "vehicle";

View file

@ -2,11 +2,11 @@
import type { ComponentCustomProperties } from "vue"; import type { ComponentCustomProperties } from "vue";
declare module "vue" { declare module "vue" {
interface ComponentCustomProperties { interface ComponentCustomProperties {
/** /**
* Whether experimental UI features should be shown. * Whether experimental UI features should be shown.
*/ */
$hiddenFeatures: () => boolean; $hiddenFeatures: () => boolean;
$refs: { [key: string]: HTMLElement | undefined }; $refs: { [key: string]: HTMLElement | undefined };
} }
} }

View file

@ -2,30 +2,30 @@ import { describe, expect, test } from "vitest";
import { cleanYaml } from "./cleanYaml"; import { cleanYaml } from "./cleanYaml";
describe("cleanYaml", () => { describe("cleanYaml", () => {
test("removes key from single line", () => { test("removes key from single line", () => {
const result = cleanYaml("key: value", "key"); const result = cleanYaml("key: value", "key");
expect(result).toBe("value"); expect(result).toBe("value");
}); });
test("removes key from multi-line yaml", () => { test("removes key from multi-line yaml", () => {
const input = `key:\n nested: value\n another: thing`; const input = `key:\n nested: value\n another: thing`;
const expected = `nested: value\nanother: thing`; const expected = `nested: value\nanother: thing`;
const result = cleanYaml(input, "key"); const result = cleanYaml(input, "key");
expect(result).toBe(expected); expect(result).toBe(expected);
}); });
test("keep untouched if key not found", () => { test("keep untouched if key not found", () => {
const result = cleanYaml("key: value", "not-found"); const result = cleanYaml("key: value", "not-found");
expect(result).toBe("key: value"); expect(result).toBe("key: value");
}); });
test("trim whitespace at the end of the line", () => { test("trim whitespace at the end of the line", () => {
const result = cleanYaml("key: \n - foo \n - bar ", "key"); const result = cleanYaml("key: \n - foo \n - bar ", "key");
expect(result).toBe("- foo\n- bar"); expect(result).toBe("- foo\n- bar");
}); });
test("should remove leading comment lines", () => { test("should remove leading comment lines", () => {
const result = cleanYaml("# this is\n# a comment\nkey: value", "key"); const result = cleanYaml("# this is\n# a comment\nkey: value", "key");
expect(result).toBe("value"); expect(result).toBe("value");
}); });
test("should note remove leading comment lines if key is not found", () => { test("should note remove leading comment lines if key is not found", () => {
const result = cleanYaml("# this is\n# a comment\nnot-found: value", "key"); const result = cleanYaml("# this is\n# a comment\nnot-found: value", "key");
expect(result).toBe("# this is\n# a comment\nnot-found: value"); expect(result).toBe("# this is\n# a comment\nnot-found: value");
}); });
}); });

View file

@ -10,34 +10,34 @@
// foo: bar // foo: bar
// """ // """
export function cleanYaml(text: string, removeKey: string) { export function cleanYaml(text: string, removeKey: string) {
if (!removeKey) return text; if (!removeKey) return text;
const result: string[] = []; const result: string[] = [];
const prefix = removeKey + ":"; const prefix = removeKey + ":";
const lines = text.split("\n"); const lines = text.split("\n");
// remove first comment lines // remove first comment lines
while (lines[0].startsWith("#")) lines.shift(); while (lines[0].startsWith("#")) lines.shift();
const [firstLine, ...restLines] = lines; const [firstLine, ...restLines] = lines;
if (!firstLine.startsWith(prefix)) { if (!firstLine.startsWith(prefix)) {
// does not start with key, skip // does not start with key, skip
return text; return text;
} else { } else {
const first = firstLine.slice(prefix.length).trim(); const first = firstLine.slice(prefix.length).trim();
if (first) { if (first) {
result.push(first); result.push(first);
} }
} }
if (restLines.length > 0) { if (restLines.length > 0) {
const indentChars = restLines[0].match(/^(\s+)/)?.[0] || ""; const indentChars = restLines[0].match(/^(\s+)/)?.[0] || "";
restLines restLines
.map((l) => (l.startsWith(indentChars) ? l.slice(indentChars.length) : l)) .map((l) => (l.startsWith(indentChars) ? l.slice(indentChars.length) : l))
.map((l) => l.trimEnd()) .map((l) => l.trimEnd())
.forEach((l) => result.push(l)); .forEach((l) => result.push(l));
} }
return result.join("\n"); return result.join("\n");
} }

View file

@ -2,14 +2,14 @@ import type { Rate } from "../types/evcc";
import type { ForecastSlot } from "../components/Forecast/types"; import type { ForecastSlot } from "../components/Forecast/types";
function convertRate(slot: ForecastSlot): Rate { function convertRate(slot: ForecastSlot): Rate {
return { return {
start: new Date(slot.start), start: new Date(slot.start),
end: new Date(slot.end), end: new Date(slot.end),
value: slot.value, value: slot.value,
}; };
} }
export default function convertRates(slots: ForecastSlot[] | null): Rate[] { export default function convertRates(slots: ForecastSlot[] | null): Rate[] {
if (!slots) return []; if (!slots) return [];
return slots.map(convertRate); return slots.map(convertRate);
} }

View file

@ -1,3 +1,3 @@
export default function <T>(obj: T): T { export default function <T>(obj: T): T {
return JSON.parse(JSON.stringify(obj)); return JSON.parse(JSON.stringify(obj));
} }

View file

@ -2,64 +2,64 @@ import type { TimeseriesEntry, SolarDetails } from "../components/Forecast/types
import deepCopy from "./deepClone"; import deepCopy from "./deepClone";
export enum ForecastType { export enum ForecastType {
Solar = "solar", Solar = "solar",
Price = "price", Price = "price",
Co2 = "co2", Co2 = "co2",
} }
// return the date in local YYYY-MM-DD format // return the date in local YYYY-MM-DD format
function toDayString(date: Date): string { function toDayString(date: Date): string {
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0"); const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`; return `${year}-${month}-${day}`;
} }
// return only slots that are on a given date, ignores slots that are in the past // return only slots that are on a given date, ignores slots that are in the past
export function filterEntriesByDate( export function filterEntriesByDate(
entries: TimeseriesEntry[], entries: TimeseriesEntry[],
dayString: string dayString: string
): TimeseriesEntry[] { ): TimeseriesEntry[] {
const now = new Date(); const now = new Date();
return entries.filter(({ ts }) => { return entries.filter(({ ts }) => {
const isPast = new Date(ts) < now; const isPast = new Date(ts) < now;
const dateMatches = toDayString(new Date(ts)) === dayString; const dateMatches = toDayString(new Date(ts)) === dayString;
return !isPast && dateMatches; return !isPast && dateMatches;
}); });
} }
// return the date in local YYYY-MM-DD format // return the date in local YYYY-MM-DD format
export function dayStringByOffset(day: number): string { export function dayStringByOffset(day: number): string {
const date = new Date(); const date = new Date();
date.setDate(date.getDate() + day); date.setDate(date.getDate() + day);
return toDayString(date); return toDayString(date);
} }
// return the highest slot for a given day (0 = today, 1 = tomorrow, etc.) // return the highest slot for a given day (0 = today, 1 = tomorrow, etc.)
export function highestSlotIndexByDay(entries: TimeseriesEntry[], day: number = 0): number { export function highestSlotIndexByDay(entries: TimeseriesEntry[], day: number = 0): number {
const dayString = dayStringByOffset(day); const dayString = dayStringByOffset(day);
const dayEntries = filterEntriesByDate(entries, dayString); const dayEntries = filterEntriesByDate(entries, dayString);
const sortedEntries = dayEntries.sort((a, b) => b.val - a.val); const sortedEntries = dayEntries.sort((a, b) => b.val - a.val);
const highestEntry = sortedEntries[0] || {}; const highestEntry = sortedEntries[0] || {};
return entries.findIndex((entry) => entry.ts === highestEntry.ts); return entries.findIndex((entry) => entry.ts === highestEntry.ts);
} }
export function adjustedSolar(solar?: SolarDetails): SolarDetails | undefined { export function adjustedSolar(solar?: SolarDetails): SolarDetails | undefined {
if (!solar?.scale) return solar; if (!solar?.scale) return solar;
const { scale } = solar; const { scale } = solar;
const result = deepCopy(solar); const result = deepCopy(solar);
if (result.today) result.today.energy *= scale; if (result.today) result.today.energy *= scale;
if (result.tomorrow) result.tomorrow.energy *= scale; if (result.tomorrow) result.tomorrow.energy *= scale;
if (result.dayAfterTomorrow) result.dayAfterTomorrow.energy *= scale; if (result.dayAfterTomorrow) result.dayAfterTomorrow.energy *= scale;
if (result.timeseries) { if (result.timeseries) {
result.timeseries.forEach((entry) => { result.timeseries.forEach((entry) => {
entry.val *= scale; entry.val *= scale;
}); });
} }
result.scale = 1 / scale; // invert to allow back-adjustment result.scale = 1 / scale; // invert to allow back-adjustment
return result; return result;
} }

View file

@ -4,31 +4,36 @@ import pluginVue from "eslint-plugin-vue";
import { defineConfigWithVueTs, vueTsConfigs } from "@vue/eslint-config-typescript"; import { defineConfigWithVueTs, vueTsConfigs } from "@vue/eslint-config-typescript";
import skipFormattingConfig from "@vue/eslint-config-prettier/skip-formatting"; import skipFormattingConfig from "@vue/eslint-config-prettier/skip-formatting";
export default defineConfigWithVueTs( export default [
js.configs.recommended, ...defineConfigWithVueTs(
...pluginVue.configs["flat/recommended"], js.configs.recommended,
vueTsConfigs.recommended, ...pluginVue.configs["flat/recommended"],
skipFormattingConfig, vueTsConfigs.recommended,
{ skipFormattingConfig,
languageOptions: { {
globals: { languageOptions: {
...globals.browser, globals: {
...globals.node, ...globals.browser,
...globals.node,
},
ecmaVersion: "latest",
sourceType: "module",
}, },
ecmaVersion: "latest", rules: {
sourceType: "module", "vue/require-default-prop": "off",
}, "vue/attribute-hyphenation": "off",
"vue/multi-word-component-names": "off",
rules: { "vue/no-reserved-component-names": "off",
"vue/require-default-prop": "off", /*"vue/no-undef-properties": "warn",*/
"vue/attribute-hyphenation": "off", "no-param-reassign": "error",
"vue/multi-word-component-names": "off", "vue/block-lang": "off",
"vue/no-reserved-component-names": "off", "@typescript-eslint/no-explicit-any": "off",
/*"vue/no-undef-properties": "warn",*/ },
"no-param-reassign": "error", }
"vue/block-lang": "off", ).map((config) => ({
"@typescript-eslint/no-explicit-any": "off", ...config,
}, files: ["assets/**/*.{ts,js,vue}", "tests/**/*.ts"],
} })),
); ];

View file

@ -5,8 +5,10 @@
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"test": "cross-env TZ=Europe/Berlin vitest", "test": "cross-env TZ=Europe/Berlin vitest",
"lint": "prettier assets ./**/*.{yml,yaml,sh} ./i18n/*.json --write && eslint assets/**/*.js assets/**/*.vue --fix --max-warnings=0 && npm run tsc", "lint": "npm run lint:prettier && npm run lint:eslint && npm run lint:tsc",
"tsc": "vue-tsc --noEmit", "lint:prettier": "prettier assets tests **/*.{yaml,sh} i18n/*.json --write",
"lint:eslint": "eslint --fix --max-warnings=0",
"lint:tsc": "vue-tsc --noEmit",
"dev": "vite", "dev": "vite",
"playwright": "playwright test --ui", "playwright": "playwright test --ui",
"simulator": "vite tests/simulator", "simulator": "vite tests/simulator",

View file

@ -7,83 +7,81 @@ const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
test.use({ baseURL: baseUrl() }); test.use({ baseURL: baseUrl() });
test.beforeAll(async () => { test.beforeAll(async () => {
await start(CONFIG_GRID_ONLY); await start(CONFIG_GRID_ONLY);
}); });
test.afterAll(async () => { test.afterAll(async () => {
await stop(); await stop();
}); });
test.describe("battery meter", async () => { test.describe("battery meter", async () => {
test("create, edit and remove battery meter", async ({ page }) => { test("create, edit and remove battery meter", async ({ page }) => {
await page.goto("/#/config"); await page.goto("/#/config");
await enableExperimental(page); await enableExperimental(page);
await expect(page.getByTestId("battery")).toHaveCount(0); await expect(page.getByTestId("battery")).toHaveCount(0);
// create #1 // create #1
await page.getByRole("button", { name: "Add solar or battery" }).click(); await page.getByRole("button", { name: "Add solar or battery" }).click();
const meterModal = page.getByTestId("meter-modal"); const meterModal = page.getByTestId("meter-modal");
await meterModal.getByRole("button", { name: "Add battery meter" }).click(); await meterModal.getByRole("button", { name: "Add battery meter" }).click();
await meterModal.getByLabel("Title").fill("Demo Battery"); await meterModal.getByLabel("Title").fill("Demo Battery");
await meterModal.getByLabel("Manufacturer").selectOption("Demo battery"); await meterModal.getByLabel("Manufacturer").selectOption("Demo battery");
await meterModal.getByLabel("Power").fill("4000"); await meterModal.getByLabel("Power").fill("4000");
await meterModal.getByLabel("Charge").fill("80"); await meterModal.getByLabel("Charge").fill("80");
await expect(meterModal.getByLabel("Maximum AC power of the hybrid inverter")).toHaveCount( await expect(meterModal.getByLabel("Maximum AC power of the hybrid inverter")).toHaveCount(0);
0 await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible();
); await meterModal.getByRole("link", { name: "validate" }).click();
await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible(); await expect(meterModal.getByTestId("device-tag-power")).toContainText("4.0 kW");
await meterModal.getByRole("link", { name: "validate" }).click(); await expect(meterModal.getByTestId("device-tag-soc")).toContainText("80.0%");
await expect(meterModal.getByTestId("device-tag-power")).toContainText("4.0 kW"); await meterModal.getByRole("button", { name: "Save" }).click();
await expect(meterModal.getByTestId("device-tag-soc")).toContainText("80.0%"); await expectModalHidden(meterModal);
await meterModal.getByRole("button", { name: "Save" }).click(); await expect(page.getByTestId("battery")).toBeVisible();
await expectModalHidden(meterModal); await expect(page.getByTestId("battery")).toContainText("Demo Battery");
await expect(page.getByTestId("battery")).toBeVisible();
await expect(page.getByTestId("battery")).toContainText("Demo Battery");
// edit #1 // edit #1
await page.getByTestId("battery").getByRole("button", { name: "edit" }).click(); await page.getByTestId("battery").getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal); await expectModalVisible(meterModal);
await meterModal.getByLabel("Power").fill("5000"); await meterModal.getByLabel("Power").fill("5000");
await meterModal.getByRole("button", { name: "Validate & save" }).click(); await meterModal.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(meterModal); await expectModalHidden(meterModal);
const battery = page.getByTestId("battery"); const battery = page.getByTestId("battery");
await expect(battery).toBeVisible(); await expect(battery).toBeVisible();
await expect(battery).toContainText("Demo Battery"); await expect(battery).toContainText("Demo Battery");
await expect(battery.getByTestId("device-tag-power")).toContainText("5.0 kW"); await expect(battery.getByTestId("device-tag-power")).toContainText("5.0 kW");
await expect(battery.getByTestId("device-tag-soc")).toContainText("80.0%"); await expect(battery.getByTestId("device-tag-soc")).toContainText("80.0%");
// restart and check in main ui // restart and check in main ui
await restart(CONFIG_GRID_ONLY); await restart(CONFIG_GRID_ONLY);
await page.goto("/"); await page.goto("/");
await page.getByTestId("visualization").click(); await page.getByTestId("visualization").click();
await expect(page.getByTestId("energyflow")).toContainText("Battery discharging80%5.0 kW"); await expect(page.getByTestId("energyflow")).toContainText("Battery discharging80%5.0 kW");
// delete #1 // delete #1
await page.goto("/#/config"); await page.goto("/#/config");
await page.getByTestId("battery").getByRole("button", { name: "edit" }).click(); await page.getByTestId("battery").getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal); await expectModalVisible(meterModal);
await meterModal.getByRole("button", { name: "Delete" }).click(); await meterModal.getByRole("button", { name: "Delete" }).click();
await expectModalHidden(meterModal); await expectModalHidden(meterModal);
await expect(page.getByTestId("battery")).toHaveCount(0); await expect(page.getByTestId("battery")).toHaveCount(0);
}); });
test("advanced fields", async ({ page }) => { test("advanced fields", async ({ page }) => {
await page.goto("/#/config"); await page.goto("/#/config");
await enableExperimental(page); await enableExperimental(page);
await page.getByRole("button", { name: "Add solar or battery" }).click(); await page.getByRole("button", { name: "Add solar or battery" }).click();
const meterModal = page.getByTestId("meter-modal"); const meterModal = page.getByTestId("meter-modal");
await meterModal.getByRole("button", { name: "Add battery meter" }).click(); await meterModal.getByRole("button", { name: "Add battery meter" }).click();
await meterModal.getByLabel("Title").fill("Demo Battery"); await meterModal.getByLabel("Title").fill("Demo Battery");
await meterModal.getByLabel("Manufacturer").selectOption("Demo battery"); await meterModal.getByLabel("Manufacturer").selectOption("Demo battery");
await expect(meterModal.getByLabel("Meter reading")).not.toBeVisible(); await expect(meterModal.getByLabel("Meter reading")).not.toBeVisible();
await page.getByRole("button", { name: "Show advanced settings" }).click(); await page.getByRole("button", { name: "Show advanced settings" }).click();
await expect(meterModal.getByLabel("Meter reading")).toBeVisible(); await expect(meterModal.getByLabel("Meter reading")).toBeVisible();
await page.getByRole("button", { name: "Hide advanced settings" }).click(); await page.getByRole("button", { name: "Hide advanced settings" }).click();
await expect(meterModal.getByLabel("Meter reading")).not.toBeVisible(); await expect(meterModal.getByLabel("Meter reading")).not.toBeVisible();
}); });
}); });

View file

@ -8,448 +8,438 @@ const CONFIG_ONE_LP = "config-one-lp.evcc.yaml";
test.use({ baseURL: baseUrl() }); test.use({ baseURL: baseUrl() });
test.afterEach(async () => { test.afterEach(async () => {
await stop(); await stop();
}); });
async function enableExperimental(page: Page) { async function enableExperimental(page: Page) {
await page await page
.getByTestId("generalconfig-experimental") .getByTestId("generalconfig-experimental")
.getByRole("button", { name: "edit" }) .getByRole("button", { name: "edit" })
.click(); .click();
await page.getByLabel("Experimental 🧪").click(); await page.getByLabel("Experimental 🧪").click();
await page.getByRole("button", { name: "Close" }).click(); await page.getByRole("button", { name: "Close" }).click();
} }
async function addDemoCharger(page: Page) { async function addDemoCharger(page: Page) {
const lpModal = page.getByTestId("loadpoint-modal"); const lpModal = page.getByTestId("loadpoint-modal");
await lpModal.getByRole("button", { name: "Add charger" }).click(); await lpModal.getByRole("button", { name: "Add charger" }).click();
const modal = page.getByTestId("charger-modal"); const modal = page.getByTestId("charger-modal");
await expectModalVisible(modal); await expectModalVisible(modal);
await modal.getByLabel("Manufacturer").selectOption("Demo charger"); await modal.getByLabel("Manufacturer").selectOption("Demo charger");
await modal.getByRole("button", { name: "Save" }).click(); await modal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(modal); await expectModalHidden(modal);
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
} }
async function addDemoMeter(page: Page, power = "0") { async function addDemoMeter(page: Page, power = "0") {
const lpModal = page.getByTestId("loadpoint-modal"); const lpModal = page.getByTestId("loadpoint-modal");
await lpModal.getByRole("button", { name: "Add dedicated charger meter" }).click(); await lpModal.getByRole("button", { name: "Add dedicated charger meter" }).click();
const modal = page.getByTestId("meter-modal"); const modal = page.getByTestId("meter-modal");
await expectModalVisible(modal); await expectModalVisible(modal);
await modal.getByLabel("Manufacturer").selectOption("Demo meter"); await modal.getByLabel("Manufacturer").selectOption("Demo meter");
await modal.getByLabel("Power").fill(power); await modal.getByLabel("Power").fill(power);
await modal.getByRole("button", { name: "Save" }).click(); await modal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(modal); await expectModalHidden(modal);
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
} }
async function addVehicle(page: Page, title: string) { async function addVehicle(page: Page, title: string) {
await page.getByRole("button", { name: "Add vehicle" }).click(); await page.getByRole("button", { name: "Add vehicle" }).click();
const modal = page.getByTestId("vehicle-modal"); const modal = page.getByTestId("vehicle-modal");
await expectModalVisible(modal); await expectModalVisible(modal);
await modal.getByLabel("Manufacturer").selectOption("Generic vehicle (without API)"); await modal.getByLabel("Manufacturer").selectOption("Generic vehicle (without API)");
await modal.getByLabel("Title").fill(title); await modal.getByLabel("Title").fill(title);
await modal.getByRole("button", { name: "Validate & save" }).click(); await modal.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(modal); await expectModalHidden(modal);
} }
async function newLoadpoint(page: Page, title: string) { async function newLoadpoint(page: Page, title: string) {
const lpModal = page.getByTestId("loadpoint-modal"); const lpModal = page.getByTestId("loadpoint-modal");
await page.getByRole("button", { name: "Add charge point" }).click(); await page.getByRole("button", { name: "Add charge point" }).click();
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await lpModal.getByLabel("Title").fill(title); await lpModal.getByLabel("Title").fill(title);
} }
test.describe("loadpoint", async () => { test.describe("loadpoint", async () => {
test("create, update and delete", async ({ page }) => { test("create, update and delete", async ({ page }) => {
await start(CONFIG_EMPTY); await start(CONFIG_EMPTY);
await page.goto("/#/config"); await page.goto("/#/config");
await enableExperimental(page); await enableExperimental(page);
const lpModal = page.getByTestId("loadpoint-modal"); const lpModal = page.getByTestId("loadpoint-modal");
const chargerModal = page.getByTestId("charger-modal"); const chargerModal = page.getByTestId("charger-modal");
await expect(page.getByTestId("loadpoint")).toHaveCount(0); await expect(page.getByTestId("loadpoint")).toHaveCount(0);
// new loadpoint // new loadpoint
await newLoadpoint(page, "Solar Carport"); await newLoadpoint(page, "Solar Carport");
await lpModal.getByRole("button", { name: "Add charger" }).click(); await lpModal.getByRole("button", { name: "Add charger" }).click();
// add charger // add charger
await chargerModal.getByLabel("Manufacturer").selectOption("Demo charger"); await chargerModal.getByLabel("Manufacturer").selectOption("Demo charger");
await chargerModal.getByLabel("Charge status").selectOption("C"); await chargerModal.getByLabel("Charge status").selectOption("C");
await chargerModal.getByLabel("Power").fill("11000"); await chargerModal.getByLabel("Power").fill("11000");
await chargerModal.getByRole("button", { name: "Save" }).click(); await chargerModal.getByRole("button", { name: "Save" }).click();
await expect(lpModal.getByLabel("Title")).toHaveValue("Solar Carport"); await expect(lpModal.getByLabel("Title")).toHaveValue("Solar Carport");
// create loadpoint // create loadpoint
await lpModal.getByRole("button", { name: "Save" }).click(); await lpModal.getByRole("button", { name: "Save" }).click();
await expect(page.getByTestId("loadpoint")).toHaveCount(1); await expect(page.getByTestId("loadpoint")).toHaveCount(1);
await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport"); await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport");
await expect(page.getByTestId("loadpoint")).toContainText("charging"); await expect(page.getByTestId("loadpoint")).toContainText("charging");
await expect(page.getByTestId("loadpoint")).toContainText("11.0 kW"); await expect(page.getByTestId("loadpoint")).toContainText("11.0 kW");
// restart button appears // restart button appears
const restartButton = await page const restartButton = await page
.getByTestId("bottom-banner") .getByTestId("bottom-banner")
.getByRole("button", { name: "Restart" }); .getByRole("button", { name: "Restart" });
await expect(restartButton).toBeVisible(); await expect(restartButton).toBeVisible();
// restart // restart
await restart(CONFIG_EMPTY); await restart(CONFIG_EMPTY);
await page.reload(); await page.reload();
await expect(page.getByTestId("loadpoint")).toHaveCount(1); await expect(page.getByTestId("loadpoint")).toHaveCount(1);
await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport"); await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport");
// update loadpoint title // update loadpoint title
await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await lpModal.getByLabel("Title").fill("Solar Carport 2"); await lpModal.getByLabel("Title").fill("Solar Carport 2");
await lpModal.getByRole("button", { name: "Save" }).click(); await lpModal.getByRole("button", { name: "Save" }).click();
await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2"); await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2");
// restart // restart
await restart(CONFIG_EMPTY); await restart(CONFIG_EMPTY);
await page.reload(); await page.reload();
await expect(page.getByTestId("loadpoint")).toHaveCount(1); await expect(page.getByTestId("loadpoint")).toHaveCount(1);
await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2"); await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2");
// update loadpoint power // update loadpoint power
await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await lpModal.getByTestId("chargerPower-22kw").click(); await lpModal.getByTestId("chargerPower-22kw").click();
// update charger mode // update charger mode
await expect(lpModal.getByRole("textbox", { name: "Charger" })).toHaveValue( await expect(lpModal.getByRole("textbox", { name: "Charger" })).toHaveValue(
"Demo charger [db:1]" "Demo charger [db:1]"
); );
await lpModal.getByRole("textbox", { name: "Charger" }).click(); await lpModal.getByRole("textbox", { name: "Charger" }).click();
await chargerModal.getByLabel("Charge status").selectOption("A"); await chargerModal.getByLabel("Charge status").selectOption("A");
await chargerModal.getByRole("button", { name: "Save" }).click(); await chargerModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(chargerModal); await expectModalHidden(chargerModal);
await lpModal.getByRole("button", { name: "Save" }).click(); await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal); await expectModalHidden(lpModal);
// restart // restart
await restart(CONFIG_EMPTY); await restart(CONFIG_EMPTY);
await page.reload(); await page.reload();
await expect(page.getByTestId("loadpoint")).toHaveCount(1); await expect(page.getByTestId("loadpoint")).toHaveCount(1);
await expect(page.getByTestId("loadpoint")).toContainText("not connected"); await expect(page.getByTestId("loadpoint")).toContainText("not connected");
await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await expect(lpModal.getByTestId("chargerPower-22kw")).toHaveClass(/active/); await expect(lpModal.getByTestId("chargerPower-22kw")).toHaveClass(/active/);
await expect(lpModal.getByLabel("Title")).toHaveValue("Solar Carport 2"); await expect(lpModal.getByLabel("Title")).toHaveValue("Solar Carport 2");
await lpModal.getByRole("button", { name: "Close" }).click(); await lpModal.getByRole("button", { name: "Close" }).click();
// delete loadpoint // delete loadpoint
await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await lpModal.getByRole("button", { name: "Delete" }).click(); await lpModal.getByRole("button", { name: "Delete" }).click();
await expect(page.getByTestId("loadpoint")).toHaveCount(0); await expect(page.getByTestId("loadpoint")).toHaveCount(0);
// restart // restart
await restart(CONFIG_EMPTY); await restart(CONFIG_EMPTY);
await page.reload(); await page.reload();
await expect(page.getByTestId("loadpoint")).toHaveCount(0); await expect(page.getByTestId("loadpoint")).toHaveCount(0);
}); });
test("priority", async ({ page }) => { test("priority", async ({ page }) => {
await start(CONFIG_ONE_LP); await start(CONFIG_ONE_LP);
await page.goto("/#/config"); await page.goto("/#/config");
await enableExperimental(page); await enableExperimental(page);
const lpModal = page.getByTestId("loadpoint-modal"); const lpModal = page.getByTestId("loadpoint-modal");
// loadpoint from yaml // loadpoint from yaml
await expect(page.getByTestId("loadpoint")).toHaveCount(1); await expect(page.getByTestId("loadpoint")).toHaveCount(1);
await expect(page.getByTestId("loadpoint")).toContainText("Carport"); await expect(page.getByTestId("loadpoint")).toContainText("Carport");
// add loadpoint via UI // add loadpoint via UI
await newLoadpoint(page, "Garage"); await newLoadpoint(page, "Garage");
await addDemoCharger(page); await addDemoCharger(page);
await lpModal.getByRole("button", { name: "Save" }).click(); await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal); await expectModalHidden(lpModal);
// two loadpoints // two loadpoints
await expect(page.getByTestId("loadpoint")).toHaveCount(2); await expect(page.getByTestId("loadpoint")).toHaveCount(2);
await expect(page.getByTestId("loadpoint").nth(0)).toContainText("Carport"); await expect(page.getByTestId("loadpoint").nth(0)).toContainText("Carport");
await expect(page.getByTestId("loadpoint").nth(1)).toContainText("Garage"); await expect(page.getByTestId("loadpoint").nth(1)).toContainText("Garage");
// second loadpoint: increase priority // second loadpoint: increase priority
await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click(); await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await expect(lpModal.getByLabel("Priority")).toHaveValue("0 (default)"); await expect(lpModal.getByLabel("Priority")).toHaveValue("0 (default)");
await lpModal.getByLabel("Priority").selectOption("1"); await lpModal.getByLabel("Priority").selectOption("1");
await lpModal.getByRole("button", { name: "Save" }).click(); await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal); await expectModalHidden(lpModal);
// restart // restart
await restart(CONFIG_ONE_LP); await restart(CONFIG_ONE_LP);
await page.reload(); await page.reload();
// check priorities // check priorities
await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click(); await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await expect(lpModal.getByLabel("Priority")).toHaveValue("1"); await expect(lpModal.getByLabel("Priority")).toHaveValue("1");
// change back to 0 // change back to 0
await lpModal.getByLabel("Priority").selectOption("0 (default)"); await lpModal.getByLabel("Priority").selectOption("0 (default)");
await lpModal.getByRole("button", { name: "Save" }).click(); await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal); await expectModalHidden(lpModal);
// restart // restart
await restart(CONFIG_ONE_LP); await restart(CONFIG_ONE_LP);
await page.reload(); await page.reload();
// check priorities // check priorities
await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click(); await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await expect(lpModal.getByLabel("Priority")).toHaveValue("0 (default)"); await expect(lpModal.getByLabel("Priority")).toHaveValue("0 (default)");
}); });
test("vehicle", async ({ page }) => { test("vehicle", async ({ page }) => {
await start(CONFIG_EMPTY); await start(CONFIG_EMPTY);
await page.goto("/#/config"); await page.goto("/#/config");
await enableExperimental(page); await enableExperimental(page);
const LP_1 = "Carport"; const LP_1 = "Carport";
const LP_2 = "Garage"; const LP_2 = "Garage";
const VEHICLE_1 = "Green Car"; const VEHICLE_1 = "Green Car";
const VEHICLE_2 = "Yellow Van"; const VEHICLE_2 = "Yellow Van";
const lpModal = page.getByTestId("loadpoint-modal"); const lpModal = page.getByTestId("loadpoint-modal");
// add loadpoint > no vehicle option // add loadpoint > no vehicle option
await newLoadpoint(page, LP_1); await newLoadpoint(page, LP_1);
await addDemoCharger(page); await addDemoCharger(page);
await expect(lpModal.getByLabel("Default vehicle")).not.toBeVisible(); await expect(lpModal.getByLabel("Default vehicle")).not.toBeVisible();
await lpModal.getByRole("button", { name: "Save" }).click(); await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal); await expectModalHidden(lpModal);
// edit loadpoint // edit loadpoint
await page.getByTestId("loadpoint").nth(0).getByRole("button", { name: "edit" }).click(); await page.getByTestId("loadpoint").nth(0).getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await expect(lpModal).toContainText("No vehicles are configured."); await expect(lpModal).toContainText("No vehicles are configured.");
await lpModal.getByRole("button", { name: "Close" }).click(); await lpModal.getByRole("button", { name: "Close" }).click();
// add vehicles // add vehicles
for (const title of [VEHICLE_1, VEHICLE_2]) { for (const title of [VEHICLE_1, VEHICLE_2]) {
await addVehicle(page, title); await addVehicle(page, title);
} }
// set vehicle as default for loadpoint 1 // set vehicle as default for loadpoint 1
await page.getByTestId("loadpoint").nth(0).getByRole("button", { name: "edit" }).click(); await page.getByTestId("loadpoint").nth(0).getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await expect(lpModal.getByTestId("loadpointPollMode-charging")).toHaveClass(/active/); await expect(lpModal.getByTestId("loadpointPollMode-charging")).toHaveClass(/active/);
await expect( await expect(lpModal.getByRole("checkbox", { name: "Interpolate charge level" })).toBeChecked();
lpModal.getByRole("checkbox", { name: "Interpolate charge level" }) await lpModal.getByLabel("Default vehicle").selectOption(VEHICLE_1);
).toBeChecked(); await lpModal.getByRole("button", { name: "Save" }).click();
await lpModal.getByLabel("Default vehicle").selectOption(VEHICLE_1); await expectModalHidden(lpModal);
await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal);
// add second loadpoint // add second loadpoint
await newLoadpoint(page, LP_2); await newLoadpoint(page, LP_2);
await addDemoCharger(page); await addDemoCharger(page);
await lpModal.getByLabel("Default vehicle").selectOption(VEHICLE_2); await lpModal.getByLabel("Default vehicle").selectOption(VEHICLE_2);
await lpModal.getByRole("button", { name: "Save" }).click(); await lpModal.getByRole("button", { name: "Save" }).click();
// restart // restart
await restart(CONFIG_EMPTY); await restart(CONFIG_EMPTY);
await page.reload(); await page.reload();
// check loadpoint default vehicles // check loadpoint default vehicles
for (const [index, vehicle] of [VEHICLE_1, VEHICLE_2].entries()) { for (const [index, vehicle] of [VEHICLE_1, VEHICLE_2].entries()) {
await page await page.getByTestId("loadpoint").nth(index).getByRole("button", { name: "edit" }).click();
.getByTestId("loadpoint") await expectModalVisible(lpModal);
.nth(index) await expect(lpModal.locator("#loadpointParamVehicle option:checked")).toHaveText(vehicle);
.getByRole("button", { name: "edit" }) await lpModal.getByRole("button", { name: "Close" }).click();
.click(); await expectModalHidden(lpModal);
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 }) => { test("keep mode", async ({ page }) => {
await start(CONFIG_EMPTY); await start(CONFIG_EMPTY);
await page.goto("/#/config"); await page.goto("/#/config");
await enableExperimental(page); await enableExperimental(page);
// add grid meter // add grid meter
await page.getByRole("button", { name: "Add grid meter" }).click(); await page.getByRole("button", { name: "Add grid meter" }).click();
const meterModal = page.getByTestId("meter-modal"); const meterModal = page.getByTestId("meter-modal");
await meterModal.getByLabel("Manufacturer").selectOption("Demo meter"); await meterModal.getByLabel("Manufacturer").selectOption("Demo meter");
await meterModal.getByLabel("Power").fill("-1000"); await meterModal.getByLabel("Power").fill("-1000");
await meterModal.getByRole("button", { name: "Save" }).click(); await meterModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(meterModal); await expectModalHidden(meterModal);
// add a loadpoint with dummy charger, // add a loadpoint with dummy charger,
const lpModal = page.getByTestId("loadpoint-modal"); const lpModal = page.getByTestId("loadpoint-modal");
await newLoadpoint(page, "Carport"); await newLoadpoint(page, "Carport");
await addDemoCharger(page); await addDemoCharger(page);
await lpModal.getByLabel("Default mode").selectOption("---"); await lpModal.getByLabel("Default mode").selectOption("---");
await lpModal.getByRole("button", { name: "Save" }).click(); await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal); await expectModalHidden(lpModal);
await restart(CONFIG_EMPTY); await restart(CONFIG_EMPTY);
// change on main ui // change on main ui
await page.goto("/"); await page.goto("/");
await expect(page.getByRole("button", { name: "Off" })).toHaveClass(/active/); await expect(page.getByRole("button", { name: "Off" })).toHaveClass(/active/);
await page.getByRole("button", { name: "Solar", exact: true }).click(); await page.getByRole("button", { name: "Solar", exact: true }).click();
await restart(CONFIG_EMPTY); await restart(CONFIG_EMPTY);
await page.reload(); await page.reload();
await expect(page.getByRole("button", { name: "Solar", exact: true })).toHaveClass( await expect(page.getByRole("button", { name: "Solar", exact: true })).toHaveClass(/active/);
/active/
);
// change default mode in config to fast // change default mode in config to fast
await page.goto("/#/config"); await page.goto("/#/config");
// open first loadpoint // open first loadpoint
await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await lpModal.getByLabel("Default mode").selectOption("Fast"); await lpModal.getByLabel("Default mode").selectOption("Fast");
await lpModal.getByRole("button", { name: "Save" }).click(); await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal); await expectModalHidden(lpModal);
await restart(CONFIG_EMPTY); await restart(CONFIG_EMPTY);
// check loadpoint mode // check loadpoint mode
await page.goto("/"); await page.goto("/");
await expect(page.getByRole("button", { name: "Fast" })).toHaveClass(/active/); await expect(page.getByRole("button", { name: "Fast" })).toHaveClass(/active/);
}); });
test("delete vehicle references", async ({ page }) => { test("delete vehicle references", async ({ page }) => {
await start(CONFIG_EMPTY); await start(CONFIG_EMPTY);
await page.goto("/#/config"); await page.goto("/#/config");
await enableExperimental(page); await enableExperimental(page);
// add vehicle, add loadpoint, add charger // add vehicle, add loadpoint, add charger
await addVehicle(page, "Porsche"); await addVehicle(page, "Porsche");
await addVehicle(page, "Tesla"); await addVehicle(page, "Tesla");
await newLoadpoint(page, "Garage"); await newLoadpoint(page, "Garage");
await addDemoCharger(page); await addDemoCharger(page);
const lpModal = page.getByTestId("loadpoint-modal"); const lpModal = page.getByTestId("loadpoint-modal");
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await lpModal.getByLabel("Default vehicle").selectOption("Porsche"); await lpModal.getByLabel("Default vehicle").selectOption("Porsche");
await lpModal.getByRole("button", { name: "Save" }).click(); await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal); await expectModalHidden(lpModal);
// delete vehicle // delete vehicle
await page.getByTestId("vehicle").nth(0).getByRole("button", { name: "edit" }).click(); await page.getByTestId("vehicle").nth(0).getByRole("button", { name: "edit" }).click();
const vehicleModal = page.getByTestId("vehicle-modal"); const vehicleModal = page.getByTestId("vehicle-modal");
await expectModalVisible(vehicleModal); await expectModalVisible(vehicleModal);
await vehicleModal.getByRole("button", { name: "Delete" }).click(); await vehicleModal.getByRole("button", { name: "Delete" }).click();
await expectModalHidden(vehicleModal); await expectModalHidden(vehicleModal);
// restart // restart
await restart(CONFIG_EMPTY); await restart(CONFIG_EMPTY);
await page.reload(); await page.reload();
// check loadpoint default vehicle // check loadpoint default vehicle
await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await expect(lpModal.getByLabel("Default vehicle")).toHaveValue(""); await expect(lpModal.getByLabel("Default vehicle")).toHaveValue("");
}); });
test("delete charger references", async ({ page }) => { test("delete charger references", async ({ page }) => {
await start(CONFIG_EMPTY); await start(CONFIG_EMPTY);
await page.goto("/#/config"); await page.goto("/#/config");
await enableExperimental(page); await enableExperimental(page);
// add loadpoint, add charger // add loadpoint, add charger
await newLoadpoint(page, "Garage"); await newLoadpoint(page, "Garage");
await addDemoCharger(page); await addDemoCharger(page);
const lpModal = page.getByTestId("loadpoint-modal"); const lpModal = page.getByTestId("loadpoint-modal");
await lpModal.getByRole("button", { name: "Save" }).click(); await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal); await expectModalHidden(lpModal);
// delete charger // delete charger
await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await lpModal.getByRole("textbox", { name: "Charger" }).click(); await lpModal.getByRole("textbox", { name: "Charger" }).click();
const chargerModal = page.getByTestId("charger-modal"); const chargerModal = page.getByTestId("charger-modal");
await expectModalVisible(chargerModal); await expectModalVisible(chargerModal);
await chargerModal.getByRole("button", { name: "Delete" }).click(); await chargerModal.getByRole("button", { name: "Delete" }).click();
await expectModalHidden(chargerModal); await expectModalHidden(chargerModal);
// restart without saving loadpoint // restart without saving loadpoint
await restart(CONFIG_EMPTY); await restart(CONFIG_EMPTY);
await page.reload(); await page.reload();
// check loadpoint default vehicle // check loadpoint default vehicle
await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await expect(lpModal.getByRole("textbox", { name: "Title" })).toHaveValue("Garage"); await expect(lpModal.getByRole("textbox", { name: "Title" })).toHaveValue("Garage");
await expect(lpModal).toContainText("Configuring a charger is required."); await expect(lpModal).toContainText("Configuring a charger is required.");
}); });
test("delete meter references", async ({ page }) => { test("delete meter references", async ({ page }) => {
await start(CONFIG_EMPTY); await start(CONFIG_EMPTY);
await page.goto("/#/config"); await page.goto("/#/config");
await enableExperimental(page); await enableExperimental(page);
// add loadpoint, add charger // add loadpoint, add charger
await newLoadpoint(page, "Garage"); await newLoadpoint(page, "Garage");
await addDemoCharger(page); await addDemoCharger(page);
await addDemoMeter(page, "11000"); await addDemoMeter(page, "11000");
const lpModal = page.getByTestId("loadpoint-modal"); const lpModal = page.getByTestId("loadpoint-modal");
await lpModal.getByRole("button", { name: "Save" }).click(); await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal); await expectModalHidden(lpModal);
await expect(page.getByTestId("loadpoint")).toContainText("11.0 kW"); await expect(page.getByTestId("loadpoint")).toContainText("11.0 kW");
// delete charger // delete charger
await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await lpModal.getByRole("textbox", { name: "Meter" }).click(); await lpModal.getByRole("textbox", { name: "Meter" }).click();
const meterModal = page.getByTestId("meter-modal"); const meterModal = page.getByTestId("meter-modal");
await meterModal.getByRole("button", { name: "Delete" }).click(); await meterModal.getByRole("button", { name: "Delete" }).click();
await expectModalHidden(meterModal); await expectModalHidden(meterModal);
// restart without saving loadpoint // restart without saving loadpoint
await restart(CONFIG_EMPTY); await restart(CONFIG_EMPTY);
await page.reload(); await page.reload();
// check loadpoint default vehicle // check loadpoint default vehicle
await expect(page.getByTestId("loadpoint")).not.toContainText("11.0 kW"); await expect(page.getByTestId("loadpoint")).not.toContainText("11.0 kW");
await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await expect(lpModal.getByRole("textbox", { name: "Title" })).toHaveValue("Garage"); await expect(lpModal.getByRole("textbox", { name: "Title" })).toHaveValue("Garage");
await expect( await expect(
lpModal.getByRole("button", { name: "Add dedicated charger meter" }) lpModal.getByRole("button", { name: "Add dedicated charger meter" })
).toBeVisible(); ).toBeVisible();
}); });
test("user-defined charger", async ({ page }) => { test("user-defined charger", async ({ page }) => {
await start(CONFIG_EMPTY); await start(CONFIG_EMPTY);
await page.goto("/#/config"); await page.goto("/#/config");
await enableExperimental(page); await enableExperimental(page);
// add loadpoint // add loadpoint
await newLoadpoint(page, "Carport"); await newLoadpoint(page, "Carport");
const lpModal = page.getByTestId("loadpoint-modal"); const lpModal = page.getByTestId("loadpoint-modal");
await lpModal.getByRole("button", { name: "Add charger" }).click(); await lpModal.getByRole("button", { name: "Add charger" }).click();
// add user-defined charger // add user-defined charger
const chargerModal = page.getByTestId("charger-modal"); const chargerModal = page.getByTestId("charger-modal");
await expectModalVisible(chargerModal); await expectModalVisible(chargerModal);
await chargerModal.getByLabel("Manufacturer").selectOption("User-defined device"); await chargerModal.getByLabel("Manufacturer").selectOption("User-defined device");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
const editor = chargerModal.getByTestId("yaml-editor"); const editor = chargerModal.getByTestId("yaml-editor");
await expect(editor).toContainText("status: # charger status [A..F]"); await expect(editor).toContainText("status: # charger status [A..F]");
await editorClear(editor, 10); await editorClear(editor, 10);
await editorPaste( await editorPaste(
editor, editor,
page, page,
`status: `status:
source: const source: const
value: 'C' value: 'C'
enabled: enabled:
@ -464,49 +454,47 @@ maxcurrent:
power: power:
source: const source: const
value: 11000` value: 11000`
); );
const restResult = chargerModal.getByTestId("test-result"); const restResult = chargerModal.getByTestId("test-result");
await expect(restResult).toContainText("Status: unknown"); await expect(restResult).toContainText("Status: unknown");
await restResult.getByRole("link", { name: "validate" }).click(); await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: successful"); await expect(restResult).toContainText("Status: successful");
await expect(restResult).toContainText(["Status", "charging"].join("")); await expect(restResult).toContainText(["Status", "charging"].join(""));
await expect(restResult).toContainText(["Enabled", "yes"].join("")); await expect(restResult).toContainText(["Enabled", "yes"].join(""));
await expect(restResult).toContainText(["Power", "11.0 kW"].join("")); await expect(restResult).toContainText(["Power", "11.0 kW"].join(""));
// create // create
await chargerModal.getByRole("button", { name: "Save" }).click(); await chargerModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(chargerModal); await expectModalHidden(chargerModal);
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await lpModal.getByRole("button", { name: "Save" }).click(); await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal); await expectModalHidden(lpModal);
await expect(page.getByTestId("loadpoint")).toHaveCount(1); await expect(page.getByTestId("loadpoint")).toHaveCount(1);
await expect(page.getByTestId("loadpoint")).toContainText("Carport"); await expect(page.getByTestId("loadpoint")).toContainText("Carport");
// restart evcc // restart evcc
await restart(CONFIG_EMPTY); await restart(CONFIG_EMPTY);
await page.reload(); await page.reload();
const lpEntry = page.getByTestId("loadpoint"); const lpEntry = page.getByTestId("loadpoint");
await expect(lpEntry).toHaveCount(1); await expect(lpEntry).toHaveCount(1);
await expect(lpEntry).toContainText("Carport"); await expect(lpEntry).toContainText("Carport");
await expect(lpEntry).toContainText(["Status", "charging"].join("")); await expect(lpEntry).toContainText(["Status", "charging"].join(""));
await expect(lpEntry).toContainText(["Enabled", "yes"].join("")); await expect(lpEntry).toContainText(["Enabled", "yes"].join(""));
await expect(lpEntry).toContainText(["Power", "11.0 kW"].join("")); await expect(lpEntry).toContainText(["Power", "11.0 kW"].join(""));
await lpEntry.getByRole("button", { name: "edit" }).click(); await lpEntry.getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal); await expectModalVisible(lpModal);
await expect(lpModal.getByLabel("Charger").first()).toHaveValue( await expect(lpModal.getByLabel("Charger").first()).toHaveValue("User-defined device [db:1]");
"User-defined device [db:1]" await lpModal.getByLabel("Charger").first().click();
); await expectModalVisible(chargerModal);
await lpModal.getByLabel("Charger").first().click();
await expectModalVisible(chargerModal);
await expect(chargerModal.getByLabel("Manufacturer")).toHaveValue("User-defined device"); await expect(chargerModal.getByLabel("Manufacturer")).toHaveValue("User-defined device");
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await expect(editor).toContainText("value: 'C'"); await expect(editor).toContainText("value: 'C'");
await expect(editor).toContainText("value: 11000"); await expect(editor).toContainText("value: 11000");
}); });
}); });

View file

@ -7,38 +7,38 @@ const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
test.use({ baseURL: baseUrl() }); test.use({ baseURL: baseUrl() });
test.afterEach(async () => { test.afterEach(async () => {
await stop(); await stop();
}); });
const SELECT_ALL = "ControlOrMeta+KeyA"; const SELECT_ALL = "ControlOrMeta+KeyA";
async function goToConfig(page: Page) { async function goToConfig(page: Page) {
await page.goto("/#/config"); await page.goto("/#/config");
await enableExperimental(page); await enableExperimental(page);
} }
test.describe("messaging", async () => { test.describe("messaging", async () => {
test("save a comment", async ({ page }) => { test("save a comment", async ({ page }) => {
await start(CONFIG_GRID_ONLY); await start(CONFIG_GRID_ONLY);
await goToConfig(page); await goToConfig(page);
await page.getByTestId("messaging").getByRole("button", { name: "edit" }).click(); await page.getByTestId("messaging").getByRole("button", { name: "edit" }).click();
const modal = await page.getByTestId("messaging-modal"); const modal = await page.getByTestId("messaging-modal");
await expectModalVisible(modal); await expectModalVisible(modal);
await modal.locator(".monaco-editor .view-line").nth(0).click(); await modal.locator(".monaco-editor .view-line").nth(0).click();
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
await page.keyboard.press(SELECT_ALL, { delay: 10 }); await page.keyboard.press(SELECT_ALL, { delay: 10 });
await page.keyboard.press("Backspace", { delay: 10 }); await page.keyboard.press("Backspace", { delay: 10 });
} }
await page.keyboard.type("# hello world"); await page.keyboard.type("# hello world");
await page.getByRole("button", { name: "Save" }).click(); await page.getByRole("button", { name: "Save" }).click();
await expectModalHidden(modal); await expectModalHidden(modal);
page.reload(); page.reload();
await page.getByTestId("messaging").getByRole("button", { name: "edit" }).click(); await page.getByTestId("messaging").getByRole("button", { name: "edit" }).click();
await expectModalVisible(modal); await expectModalVisible(modal);
await expect(modal).toContainText("# hello world"); await expect(modal).toContainText("# hello world");
}); });
}); });

View file

@ -30,10 +30,7 @@ test.describe("mqtt", async () => {
test.beforeAll(async () => { test.beforeAll(async () => {
mqttReachable = await isMqttReachable(VALID_BROKER, VALID_USERNAME, VALID_PASSWORD); mqttReachable = await isMqttReachable(VALID_BROKER, VALID_USERNAME, VALID_PASSWORD);
}); });
test.skip( test.skip(!mqttReachable, `MQTT broker ${VALID_BROKER} is not reachable, skipping tests`);
!mqttReachable,
`MQTT broker ${VALID_BROKER} is not reachable, skipping tests`
);
test("mqtt not configured", async ({ page }) => { test("mqtt not configured", async ({ page }) => {
await expect(page.getByTestId("mqtt")).toBeVisible(); await expect(page.getByTestId("mqtt")).toBeVisible();

View file

@ -8,109 +8,107 @@ const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
test.use({ baseURL: baseUrl() }); test.use({ baseURL: baseUrl() });
test.beforeAll(async () => { test.beforeAll(async () => {
await start(CONFIG_GRID_ONLY); await start(CONFIG_GRID_ONLY);
}); });
test.afterAll(async () => { test.afterAll(async () => {
await stop(); await stop();
}); });
test.describe("pv meter", async () => { test.describe("pv meter", async () => {
test("create, edit and remove pv meter", async ({ page }) => { test("create, edit and remove pv meter", async ({ page }) => {
await page.goto("/#/config"); await page.goto("/#/config");
await enableExperimental(page); await enableExperimental(page);
await expect(page.getByTestId("pv")).toHaveCount(0); await expect(page.getByTestId("pv")).toHaveCount(0);
// create #1 // create #1
await page.getByRole("button", { name: "Add solar or battery" }).click(); await page.getByRole("button", { name: "Add solar or battery" }).click();
const meterModal = page.getByTestId("meter-modal"); const meterModal = page.getByTestId("meter-modal");
await meterModal.getByRole("button", { name: "Add solar meter" }).click(); await meterModal.getByRole("button", { name: "Add solar meter" }).click();
await meterModal.getByLabel("Title").fill("PV North"); await meterModal.getByLabel("Title").fill("PV North");
await meterModal.getByLabel("Manufacturer").selectOption("Demo meter"); await meterModal.getByLabel("Manufacturer").selectOption("Demo meter");
await meterModal.getByLabel("Power optional").fill("5000"); await meterModal.getByLabel("Power optional").fill("5000");
await page.getByRole("button", { name: "Show advanced settings" }).click(); await page.getByRole("button", { name: "Show advanced settings" }).click();
await expect(meterModal.getByLabel("Minimum charge")).not.toBeVisible(); // battery usage only await expect(meterModal.getByLabel("Minimum charge")).not.toBeVisible(); // battery usage only
await expect( await expect(meterModal.getByLabel("Maximum AC power of the hybrid inverter")).toBeVisible(); // pv usage only
meterModal.getByLabel("Maximum AC power of the hybrid inverter") await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible();
).toBeVisible(); // pv usage only await meterModal.getByRole("link", { name: "validate" }).click();
await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible(); await expect(meterModal.getByTestId("device-tag-power")).toContainText("5.0 kW");
await meterModal.getByRole("link", { name: "validate" }).click(); await meterModal.getByRole("button", { name: "Save" }).click();
await expect(meterModal.getByTestId("device-tag-power")).toContainText("5.0 kW"); await expectModalHidden(meterModal);
await meterModal.getByRole("button", { name: "Save" }).click(); await expect(page.getByTestId("pv")).toBeVisible();
await expectModalHidden(meterModal); await expect(page.getByTestId("pv")).toContainText("PV North");
await expect(page.getByTestId("pv")).toBeVisible();
await expect(page.getByTestId("pv")).toContainText("PV North");
// edit #1 // edit #1
await page.getByTestId("pv").getByRole("button", { name: "edit" }).click(); await page.getByTestId("pv").getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal); await expectModalVisible(meterModal);
await meterModal.getByLabel("Power optional").fill("6000"); await meterModal.getByLabel("Power optional").fill("6000");
await meterModal.getByRole("button", { name: "Validate & save" }).click(); await meterModal.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(meterModal); await expectModalHidden(meterModal);
const pv = page.getByTestId("pv"); const pv = page.getByTestId("pv");
await expect(pv).toBeVisible(); await expect(pv).toBeVisible();
await expect(pv).toContainText("PV North"); await expect(pv).toContainText("PV North");
await expect(pv.getByTestId("device-tag-power")).toContainText("6.0 kW"); await expect(pv.getByTestId("device-tag-power")).toContainText("6.0 kW");
// restart and check in main ui // restart and check in main ui
await restart(CONFIG_GRID_ONLY); await restart(CONFIG_GRID_ONLY);
await page.goto("/"); await page.goto("/");
await page.getByTestId("visualization").click(); await page.getByTestId("visualization").click();
await expect(page.getByTestId("energyflow")).toContainText("Production6.0 kW"); await expect(page.getByTestId("energyflow")).toContainText("Production6.0 kW");
// delete #1 // delete #1
await page.goto("/#/config"); await page.goto("/#/config");
await page.getByTestId("pv").getByRole("button", { name: "edit" }).click(); await page.getByTestId("pv").getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal); await expectModalVisible(meterModal);
await meterModal.getByRole("button", { name: "Delete" }).click(); await meterModal.getByRole("button", { name: "Delete" }).click();
await expectModalHidden(meterModal); await expectModalHidden(meterModal);
await expect(page.getByTestId("pv")).toHaveCount(0); await expect(page.getByTestId("pv")).toHaveCount(0);
// restart and check again // restart and check again
await restart(CONFIG_GRID_ONLY); await restart(CONFIG_GRID_ONLY);
await page.reload(); await page.reload();
await expect(page.getByTestId("pv")).toHaveCount(0); await expect(page.getByTestId("pv")).toHaveCount(0);
}); });
test("remove broken pv meter", async ({ page }) => { test("remove broken pv meter", async ({ page }) => {
// setup test data for mock openems api // setup test data for mock openems api
await startSimulator(); await startSimulator();
await page.goto("/#/config"); await page.goto("/#/config");
await enableExperimental(page); await enableExperimental(page);
// create meter // create meter
await page.getByRole("button", { name: "Add solar or battery" }).click(); await page.getByRole("button", { name: "Add solar or battery" }).click();
const meterModal = page.getByTestId("meter-modal"); const meterModal = page.getByTestId("meter-modal");
await meterModal.getByRole("button", { name: "Add solar meter" }).click(); await meterModal.getByRole("button", { name: "Add solar meter" }).click();
await meterModal.getByLabel("Title").fill("North Roof"); await meterModal.getByLabel("Title").fill("North Roof");
await meterModal.getByLabel("Manufacturer").selectOption("shelly-1pm"); await meterModal.getByLabel("Manufacturer").selectOption("shelly-1pm");
await meterModal.getByLabel("IP address or hostname").fill(simulatorHost()); await meterModal.getByLabel("IP address or hostname").fill(simulatorHost());
await meterModal.getByRole("button", { name: "Validate & save" }).click(); await meterModal.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(meterModal); await expectModalHidden(meterModal);
await expect(page.getByTestId("pv")).toBeVisible(); await expect(page.getByTestId("pv")).toBeVisible();
await expect(page.getByTestId("pv")).toContainText("North Roof"); await expect(page.getByTestId("pv")).toContainText("North Roof");
// break meter // break meter
await stopSimulator(); await stopSimulator();
await restart(CONFIG_GRID_ONLY); await restart(CONFIG_GRID_ONLY);
await page.reload(); await page.reload();
// remove meter // remove meter
await expect(page.getByTestId("fatal-error")).toBeVisible(); await expect(page.getByTestId("fatal-error")).toBeVisible();
await expect(page.getByTestId("pv")).toBeVisible(); await expect(page.getByTestId("pv")).toBeVisible();
await page.getByTestId("pv").getByRole("button", { name: "edit" }).click(); await page.getByTestId("pv").getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal); await expectModalVisible(meterModal);
await meterModal.getByRole("button", { name: "Delete" }).click(); await meterModal.getByRole("button", { name: "Delete" }).click();
await expectModalHidden(meterModal); await expectModalHidden(meterModal);
await expect(page.getByTestId("pv")).toHaveCount(0); await expect(page.getByTestId("pv")).toHaveCount(0);
// restart and check again // restart and check again
await restart(CONFIG_GRID_ONLY); await restart(CONFIG_GRID_ONLY);
await page.reload(); await page.reload();
await expect(page.getByTestId("pv")).toHaveCount(0); await expect(page.getByTestId("pv")).toHaveCount(0);
await expect(page.getByTestId("fatal-error")).not.toBeVisible(); await expect(page.getByTestId("fatal-error")).not.toBeVisible();
}); });
}); });

View file

@ -1,11 +1,11 @@
import { test, expect, type Page } from "@playwright/test"; import { test, expect, type Page } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc"; import { start, stop, restart, baseUrl } from "./evcc";
import { import {
enableExperimental, enableExperimental,
expectModalHidden, expectModalHidden,
expectModalVisible, expectModalVisible,
editorClear, editorClear,
editorPaste, editorPaste,
} from "./utils"; } from "./utils";
const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml"; const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
@ -15,92 +15,92 @@ test.use({ baseURL: baseUrl() });
test.describe.configure({ mode: "parallel" }); test.describe.configure({ mode: "parallel" });
test.afterEach(async () => { test.afterEach(async () => {
await stop(); await stop();
}); });
async function goToConfig(page: Page) { async function goToConfig(page: Page) {
await page.goto("/#/config"); await page.goto("/#/config");
await enableExperimental(page); await enableExperimental(page);
} }
test.describe("tariffs", async () => { test.describe("tariffs", async () => {
test("tariffs not configured", async ({ page }) => { test("tariffs not configured", async ({ page }) => {
await start(CONFIG_GRID_ONLY); await start(CONFIG_GRID_ONLY);
await goToConfig(page); await goToConfig(page);
await expect(page.getByTestId("tariffs")).not.toBeVisible(); await expect(page.getByTestId("tariffs")).not.toBeVisible();
await expect(page.getByTestId("add-tariffs")).toBeVisible(); await expect(page.getByTestId("add-tariffs")).toBeVisible();
}); });
test("tariffs via ui", async ({ page }) => { test("tariffs via ui", async ({ page }) => {
await start(CONFIG_GRID_ONLY); await start(CONFIG_GRID_ONLY);
await goToConfig(page); await goToConfig(page);
await page.getByTestId("add-tariffs").click(); await page.getByTestId("add-tariffs").click();
const modal = await page.getByTestId("tariffs-modal"); const modal = await page.getByTestId("tariffs-modal");
await expectModalVisible(modal); await expectModalVisible(modal);
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
// default content // default content
const editor = modal.getByTestId("yaml-editor"); const editor = modal.getByTestId("yaml-editor");
await expect(editor).toContainText("#currency: EUR"); await expect(editor).toContainText("#currency: EUR");
// clear and enter invalid yaml // clear and enter invalid yaml
await editorClear(editor); await editorClear(editor);
await editorPaste(editor, page, "foo: bar"); await editorPaste(editor, page, "foo: bar");
await page.getByRole("button", { name: "Save" }).click(); await page.getByRole("button", { name: "Save" }).click();
await expect(modal.getByTestId("error")).toContainText("invalid keys: foo"); await expect(modal.getByTestId("error")).toContainText("invalid keys: foo");
// clear and enter valid yaml // clear and enter valid yaml
await editorClear(editor); await editorClear(editor);
await editorPaste( await editorPaste(
editor, editor,
page, page,
`currency: CHF `currency: CHF
grid: grid:
type: fixed type: fixed
price: 0.123` price: 0.123`
); );
await page.getByRole("button", { name: "Save" }).click(); await page.getByRole("button", { name: "Save" }).click();
await expect(modal.getByTestId("error")).not.toBeVisible(); await expect(modal.getByTestId("error")).not.toBeVisible();
// modal closes // modal closes
await expectModalHidden(modal); await expectModalHidden(modal);
// restart button appears // restart button appears
const restartButton = await page const restartButton = await page
.getByTestId("bottom-banner") .getByTestId("bottom-banner")
.getByRole("button", { name: "Restart" }); .getByRole("button", { name: "Restart" });
await expect(restartButton).toBeVisible(); await expect(restartButton).toBeVisible();
await restart(CONFIG_GRID_ONLY); await restart(CONFIG_GRID_ONLY);
// restart done // restart done
await expect(restartButton).not.toBeVisible(); await expect(restartButton).not.toBeVisible();
await expect(page.getByTestId("tariffs")).toContainText( await expect(page.getByTestId("tariffs")).toContainText(
["Currency", "CHF", "Grid price", "12.3 rp"].join("") ["Currency", "CHF", "Grid price", "12.3 rp"].join("")
); );
}); });
test("tariffs from evcc.yaml", async ({ page }) => { test("tariffs from evcc.yaml", async ({ page }) => {
await start(CONFIG_WITH_TARIFFS); await start(CONFIG_WITH_TARIFFS);
await goToConfig(page); await goToConfig(page);
await expect(page.getByTestId("tariffs")).toBeVisible(); await expect(page.getByTestId("tariffs")).toBeVisible();
await expect(page.getByTestId("tariffs")).toContainText( await expect(page.getByTestId("tariffs")).toContainText(
[ [
"Tariffs", "Tariffs",
"Currency", "Currency",
"SEK", "SEK",
"Grid price", "Grid price",
"30.0 öre", "30.0 öre",
"Feed-in price", "Feed-in price",
"-10.0 öre", "-10.0 öre",
"Grid CO₂", "Grid CO₂",
"300 g", "300 g",
].join("") ].join("")
); );
}); });
}); });

View file

@ -9,132 +9,132 @@ import { Transform } from "stream";
const BINARY = "./evcc"; const BINARY = "./evcc";
function workerPort() { function workerPort() {
const index = Number(process.env["TEST_WORKER_INDEX"] ?? 0); const index = Number(process.env["TEST_WORKER_INDEX"] ?? 0);
return 11000 + index; return 11000 + index;
} }
function logPrefix() { function logPrefix() {
return `[worker:${process.env["TEST_WORKER_INDEX"]}]`; return `[worker:${process.env["TEST_WORKER_INDEX"]}]`;
} }
function createSteamLog() { function createSteamLog() {
return new Transform({ return new Transform({
transform(chunk: Buffer, _, callback) { transform(chunk: Buffer, _, callback) {
const lines = chunk.toString().split("\n"); const lines = chunk.toString().split("\n");
lines.forEach((line: string) => { lines.forEach((line: string) => {
if (line.trim()) log(line); if (line.trim()) log(line);
}); });
callback(); callback();
}, },
}); });
} }
function log(...args: any[]) { function log(...args: any[]) {
console.log(logPrefix(), ...args); console.log(logPrefix(), ...args);
} }
export function baseUrl() { export function baseUrl() {
return `http://localhost:${workerPort()}`; return `http://localhost:${workerPort()}`;
} }
function dbPath() { function dbPath() {
const file = `evcc-${workerPort()}.db`; const file = `evcc-${workerPort()}.db`;
return path.join(os.tmpdir(), file); return path.join(os.tmpdir(), file);
} }
export async function start( export async function start(
config: string, config: string,
sqlDumps?: string | null, sqlDumps?: string | null,
flags: string | string[] = "--disable-auth" flags: string | string[] = "--disable-auth"
) { ) {
await _clean(); await _clean();
if (sqlDumps) { if (sqlDumps) {
await _restoreDatabase(sqlDumps); await _restoreDatabase(sqlDumps);
} }
return await _start(config, flags); return await _start(config, flags);
} }
export async function stop(instance?: ChildProcess) { export async function stop(instance?: ChildProcess) {
await _stop(instance); await _stop(instance);
await _clean(); await _clean();
} }
export async function restart(config: string, flags = "--disable-auth") { export async function restart(config: string, flags = "--disable-auth") {
await _stop(); await _stop();
await _start(config, flags); await _start(config, flags);
} }
export async function cleanRestart(config: string, sqlDumps: string) { export async function cleanRestart(config: string, sqlDumps: string) {
await _stop(); await _stop();
await _clean(); await _clean();
if (sqlDumps) { if (sqlDumps) {
await _restoreDatabase(sqlDumps); await _restoreDatabase(sqlDumps);
} }
await _start(config); await _start(config);
} }
async function _restoreDatabase(sqlDumps: string) { async function _restoreDatabase(sqlDumps: string) {
const dumps = Array.isArray(sqlDumps) ? sqlDumps : [sqlDumps]; const dumps = Array.isArray(sqlDumps) ? sqlDumps : [sqlDumps];
for (const dump of dumps) { for (const dump of dumps) {
log("loading database", dbPath(), dump); log("loading database", dbPath(), dump);
execSync(`sqlite3 ${dbPath()} < tests/${dump}`); execSync(`sqlite3 ${dbPath()} < tests/${dump}`);
} }
} }
async function _start(config: string, flags: string | string[] = []) { async function _start(config: string, flags: string | string[] = []) {
const configFile = config.includes("/") ? config : `tests/${config}`; const configFile = config.includes("/") ? config : `tests/${config}`;
const port = workerPort(); const port = workerPort();
log(`wait until port ${port} is available`); log(`wait until port ${port} is available`);
// wait for port to be available // wait for port to be available
await waitOn({ resources: [`tcp:${port}`], reverse: true, log: true }); await waitOn({ resources: [`tcp:${port}`], reverse: true, log: true });
const additionalFlags = typeof flags === "string" ? [flags] : flags; const additionalFlags = typeof flags === "string" ? [flags] : flags;
additionalFlags.push("--log", "debug,httpd:trace"); additionalFlags.push("--log", "debug,httpd:trace");
log("starting evcc", { config, port, additionalFlags }); log("starting evcc", { config, port, additionalFlags });
const instance = spawn(BINARY, ["--config", configFile, ...additionalFlags], { const instance = spawn(BINARY, ["--config", configFile, ...additionalFlags], {
env: { EVCC_NETWORK_PORT: port.toString(), EVCC_DATABASE_DSN: dbPath() }, env: { EVCC_NETWORK_PORT: port.toString(), EVCC_DATABASE_DSN: dbPath() },
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
}); });
const steamLog = createSteamLog(); const steamLog = createSteamLog();
instance.stdout.pipe(steamLog); instance.stdout.pipe(steamLog);
instance.stderr.pipe(steamLog); instance.stderr.pipe(steamLog);
instance.on("exit", (code) => { instance.on("exit", (code) => {
log("evcc terminated", { code, port, config }); log("evcc terminated", { code, port, config });
steamLog.end(); steamLog.end();
}); });
await waitOn({ resources: [baseUrl()], log: true }); await waitOn({ resources: [baseUrl()], log: true });
return instance; return instance;
} }
async function _stop(instance?: ChildProcess) { async function _stop(instance?: ChildProcess) {
const port = workerPort(); const port = workerPort();
if (instance) { if (instance) {
log("shutting down evcc hard", { port }); log("shutting down evcc hard", { port });
instance.kill("SIGKILL"); instance.kill("SIGKILL");
await waitOn({ resources: [`tcp:${port}`], reverse: true, log: true }); await waitOn({ resources: [`tcp:${port}`], reverse: true, log: true });
log("evcc is down", { port }); log("evcc is down", { port });
return; return;
} }
// check if auth is required // check if auth is required
const res = await axios.get(`${baseUrl()}/api/auth/status`); const res = await axios.get(`${baseUrl()}/api/auth/status`);
log("auth status", res.status, res.statusText, res.data); log("auth status", res.status, res.statusText, res.data);
let cookie; let cookie;
// login required // login required
if (!res.data) { if (!res.data) {
const res = await axios.post(`${baseUrl()}/api/auth/login`, { password: "secret" }); const res = await axios.post(`${baseUrl()}/api/auth/login`, { password: "secret" });
log("login", res.status, res.statusText); log("login", res.status, res.statusText);
cookie = res.headers["set-cookie"]; cookie = res.headers["set-cookie"];
} }
log("shutting down evcc", { port }); log("shutting down evcc", { port });
await axios.post(`${baseUrl()}/api/system/shutdown`, {}, { headers: { cookie } }); await axios.post(`${baseUrl()}/api/system/shutdown`, {}, { headers: { cookie } });
log(`wait until port ${port} is closed`); log(`wait until port ${port} is closed`);
await waitOn({ resources: [`tcp:${port}`], reverse: true, log: true }); await waitOn({ resources: [`tcp:${port}`], reverse: true, log: true });
log("evcc is down", { port }); log("evcc is down", { port });
} }
async function _clean() { async function _clean() {
const db = dbPath(); const db = dbPath();
if (fs.existsSync(db)) { if (fs.existsSync(db)) {
log("delete database", db); log("delete database", db);
fs.unlinkSync(db); fs.unlinkSync(db);
} }
} }

View file

@ -1,25 +1,25 @@
import mqtt from "mqtt"; import mqtt from "mqtt";
export async function isMqttReachable( export async function isMqttReachable(
broker: string, broker: string,
username: string, username: string,
password: string password: string
): Promise<boolean> { ): Promise<boolean> {
try { try {
const client = mqtt.connect(`mqtt://${broker}`, { const client = mqtt.connect(`mqtt://${broker}`, {
connectTimeout: 2000, connectTimeout: 2000,
username, username,
password, password,
}); });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
client.once("connect", () => resolve()); client.once("connect", () => resolve());
client.once("error", (err) => reject(err)); client.once("error", (err) => reject(err));
}); });
client.end(); client.end();
return true; // connection successful return true; // connection successful
} catch { } catch {
return false; // connection failed return false; // connection failed
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -7,254 +7,252 @@ const mobile = devices["iPhone 12 Mini"].viewport;
const desktop = devices["Desktop Chrome"].viewport; const desktop = devices["Desktop Chrome"].viewport;
test.beforeAll(async () => { test.beforeAll(async () => {
await start("basics.evcc.yaml", "sessions.sql"); await start("basics.evcc.yaml", "sessions.sql");
}); });
test.afterAll(async () => { test.afterAll(async () => {
await stop(); await stop();
}); });
async function selectLoadpointFilter(page: Page, value: string) { async function selectLoadpointFilter(page: Page, value: string) {
await page await page
.getByTestId("filter-loadpoint") .getByTestId("filter-loadpoint")
.locator("visible=true") .locator("visible=true")
.getByRole("combobox") .getByRole("combobox")
.selectOption(value); .selectOption(value);
} }
async function selectVehicleFilter(page: Page, value: string) { async function selectVehicleFilter(page: Page, value: string) {
await page await page
.getByTestId("filter-vehicle") .getByTestId("filter-vehicle")
.locator("visible=true") .locator("visible=true")
.getByRole("combobox") .getByRole("combobox")
.selectOption(value); .selectOption(value);
} }
test.describe("basics", async () => { test.describe("basics", async () => {
test("navigation to sessions", async ({ page }) => { test("navigation to sessions", async ({ page }) => {
await page.goto("/"); await page.goto("/");
const topNavigationButton = await page.getByTestId("topnavigation-button"); const topNavigationButton = await page.getByTestId("topnavigation-button");
await topNavigationButton.click(); await topNavigationButton.click();
await page await page
.getByTestId("topnavigation-dropdown") .getByTestId("topnavigation-dropdown")
.getByRole("link", { name: "Charging sessions" }) .getByRole("link", { name: "Charging sessions" })
.click(); .click();
await expect(page.getByRole("heading", { name: "Charging Sessions" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Charging Sessions" })).toBeVisible();
}); });
test("month without data", async ({ page }) => { test("month without data", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=6"); await page.goto("/#/sessions?year=2023&month=6");
await expect(page.getByTestId("sessions-nodata")).toHaveCount(1); await expect(page.getByTestId("sessions-nodata")).toHaveCount(1);
await expect(page.getByTestId("sessions-entry")).toHaveCount(0); await expect(page.getByTestId("sessions-entry")).toHaveCount(0);
}); });
test("month with data", async ({ page }) => { test("month with data", async ({ page }) => {
await page.setViewportSize(desktop); await page.setViewportSize(desktop);
await page.goto("/#/sessions?year=2023&month=5"); await page.goto("/#/sessions?year=2023&month=5");
await expect(page.getByTestId("navigate-month")).toHaveText("May"); await expect(page.getByTestId("navigate-month")).toHaveText("May");
await expect(page.getByTestId("navigate-year")).toHaveText("2023"); await expect(page.getByTestId("navigate-year")).toHaveText("2023");
await expect(page.getByTestId("sessions-nodata")).toHaveCount(0); await expect(page.getByTestId("sessions-nodata")).toHaveCount(0);
await expect(page.getByRole("table")).toBeVisible(); await expect(page.getByRole("table")).toBeVisible();
await expect(page.getByTestId("sessions-head")).toHaveCount(1); await expect(page.getByTestId("sessions-head")).toHaveCount(1);
await expect(page.getByTestId("sessions-head").locator("th")).toHaveCount(9); await expect(page.getByTestId("sessions-head").locator("th")).toHaveCount(9);
await expect(page.getByTestId("sessions-head-energy")).toContainText("ChargedkWh"); await expect(page.getByTestId("sessions-head-energy")).toContainText("ChargedkWh");
await expect(page.getByTestId("sessions-foot-energy")).toBeVisible(); await expect(page.getByTestId("sessions-foot-energy")).toBeVisible();
await expect(page.getByTestId("sessions-foot-energy")).toHaveText("20.0"); await expect(page.getByTestId("sessions-foot-energy")).toHaveText("20.0");
await expect(page.getByTestId("sessions-head-solar")).toContainText("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")).toBeVisible();
await expect(page.getByTestId("sessions-foot-solar")).toHaveText("67.3"); await expect(page.getByTestId("sessions-foot-solar")).toHaveText("67.3");
await expect(page.getByTestId("sessions-head-price")).toContainText("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")).toBeVisible();
await expect(page.getByTestId("sessions-foot-price")).toHaveText("5.50"); 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-head-avgPrice")).toContainText("⌀ Pricect/kWh");
await expect(page.getByTestId("sessions-foot-avgPrice")).toBeVisible(); await expect(page.getByTestId("sessions-foot-avgPrice")).toBeVisible();
await expect(page.getByTestId("sessions-foot-avgPrice")).toHaveText("27.5"); await expect(page.getByTestId("sessions-foot-avgPrice")).toHaveText("27.5");
await expect(page.getByTestId("sessions-head-chargeDuration")).toContainText( await expect(page.getByTestId("sessions-head-chargeDuration")).toContainText("Durationh:mm");
"Durationh:mm" await expect(page.getByTestId("sessions-foot-chargeDuration")).toBeVisible();
); await expect(page.getByTestId("sessions-foot-chargeDuration")).toHaveText("1:30");
await expect(page.getByTestId("sessions-foot-chargeDuration")).toBeVisible();
await expect(page.getByTestId("sessions-foot-chargeDuration")).toHaveText("1:30");
await page await page
.getByTestId("sessions-head-chargeDuration") .getByTestId("sessions-head-chargeDuration")
.getByRole("combobox") .getByRole("combobox")
.selectOption("⌀ Power"); .selectOption("⌀ Power");
await expect(page.getByTestId("sessions-head-avgPower")).toContainText("⌀ PowerkW"); await expect(page.getByTestId("sessions-head-avgPower")).toContainText("⌀ PowerkW");
await expect(page.getByTestId("sessions-foot-avgPower")).toBeVisible(); await expect(page.getByTestId("sessions-foot-avgPower")).toBeVisible();
await expect(page.getByTestId("sessions-foot-avgPower")).toHaveText("10.0"); await expect(page.getByTestId("sessions-foot-avgPower")).toHaveText("10.0");
await expect(page.getByTestId("sessions-entry")).toHaveCount(4); await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
}); });
}); });
test.describe("mobile basics", async () => { test.describe("mobile basics", async () => {
test("column select", async ({ page }) => { test("column select", async ({ page }) => {
await page.setViewportSize(mobile); await page.setViewportSize(mobile);
await page.goto("/#/sessions?year=2023&month=5"); await page.goto("/#/sessions?year=2023&month=5");
await expect(page.getByTestId("sessions-head").locator("th")).toHaveCount(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-head-energy")).toContainText("ChargedkWh");
await expect(page.getByTestId("sessions-foot-energy")).toBeVisible(); await expect(page.getByTestId("sessions-foot-energy")).toBeVisible();
await expect(page.getByTestId("sessions-foot-energy")).toHaveText("20.0"); await expect(page.getByTestId("sessions-foot-energy")).toHaveText("20.0");
await page.getByTestId("sessions-head-energy").getByRole("combobox").selectOption("Solar"); await page.getByTestId("sessions-head-energy").getByRole("combobox").selectOption("Solar");
await expect(page.getByTestId("sessions-head-solar")).toContainText("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")).toBeVisible();
await expect(page.getByTestId("sessions-foot-solar")).toHaveText("67.3"); await expect(page.getByTestId("sessions-foot-solar")).toHaveText("67.3");
await page.getByTestId("sessions-head-solar").getByRole("combobox").selectOption("Cost"); await page.getByTestId("sessions-head-solar").getByRole("combobox").selectOption("Cost");
await expect(page.getByTestId("sessions-head-price")).toContainText("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")).toBeVisible();
await expect(page.getByTestId("sessions-foot-price")).toHaveText("5.50"); await expect(page.getByTestId("sessions-foot-price")).toHaveText("5.50");
await page.getByTestId("sessions-head-price").getByRole("combobox").selectOption("⌀ Price"); 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-head-avgPrice")).toContainText("⌀ Pricect/kWh");
await expect(page.getByTestId("sessions-foot-avgPrice")).toBeVisible(); await expect(page.getByTestId("sessions-foot-avgPrice")).toBeVisible();
await expect(page.getByTestId("sessions-foot-avgPrice")).toHaveText("27.5"); await expect(page.getByTestId("sessions-foot-avgPrice")).toHaveText("27.5");
}); });
test("keep selection when paging", async ({ page }) => { test("keep selection when paging", async ({ page }) => {
await page.setViewportSize(mobile); await page.setViewportSize(mobile);
await page.goto("/#/sessions?year=2023&month=5"); await page.goto("/#/sessions?year=2023&month=5");
await page.getByTestId("sessions-head-energy").getByRole("combobox").selectOption("Solar"); await page.getByTestId("sessions-head-energy").getByRole("combobox").selectOption("Solar");
await page.getByTestId("navigate-next-year-month").click(); await page.getByTestId("navigate-next-year-month").click();
await page.getByTestId("navigate-prev-year-month").click(); await page.getByTestId("navigate-prev-year-month").click();
await expect(page.getByTestId("sessions-head-solar")).toContainText("Solar%"); await expect(page.getByTestId("sessions-head-solar")).toContainText("Solar%");
}); });
}); });
test.describe("paging", async () => { test.describe("paging", async () => {
test("prev/next links", async ({ page }) => { test("prev/next links", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5"); await page.goto("/#/sessions?year=2023&month=5");
await expect(page.getByTestId("navigate-next-month")).not.toBeDisabled(); await expect(page.getByTestId("navigate-next-month")).not.toBeDisabled();
await expect(page.getByTestId("navigate-prev-month")).not.toBeDisabled(); await expect(page.getByTestId("navigate-prev-month")).not.toBeDisabled();
}); });
test("next month", async ({ page }) => { test("next month", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5"); await page.goto("/#/sessions?year=2023&month=5");
await page.getByTestId("navigate-next-month").click(); await page.getByTestId("navigate-next-month").click();
await expect(page.getByTestId("navigate-month")).toHaveText("June"); await expect(page.getByTestId("navigate-month")).toHaveText("June");
await expect(page.getByTestId("navigate-year")).toHaveText("2023"); await expect(page.getByTestId("navigate-year")).toHaveText("2023");
await expect(page.getByTestId("navigate-next-month")).not.toBeDisabled(); await expect(page.getByTestId("navigate-next-month")).not.toBeDisabled();
await expect(page.getByTestId("navigate-prev-month")).not.toBeDisabled(); await expect(page.getByTestId("navigate-prev-month")).not.toBeDisabled();
}); });
test("prev month", async ({ page }) => { test("prev month", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=6"); await page.goto("/#/sessions?year=2023&month=6");
await page.getByTestId("navigate-prev-month").click(); await page.getByTestId("navigate-prev-month").click();
await expect(page.getByTestId("navigate-month")).toHaveText("May"); await expect(page.getByTestId("navigate-month")).toHaveText("May");
await expect(page.getByTestId("navigate-year")).toHaveText("2023"); await expect(page.getByTestId("navigate-year")).toHaveText("2023");
}); });
}); });
for (const [name, viewport] of Object.entries({ desktop, mobile })) { for (const [name, viewport] of Object.entries({ desktop, mobile })) {
test.describe(`filter on ${name}`, async () => { test.describe(`filter on ${name}`, async () => {
test("by vehicle", async ({ page }) => { test("by vehicle", async ({ page }) => {
await page.setViewportSize(viewport); await page.setViewportSize(viewport);
await page.goto("/#/sessions?year=2023&month=5"); await page.goto("/#/sessions?year=2023&month=5");
await expect(page.getByTestId("sessions-entry")).toHaveCount(4); await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
await selectVehicleFilter(page, "blauer e-Golf (2)"); await selectVehicleFilter(page, "blauer e-Golf (2)");
await expect(page.getByTestId("sessions-entry")).toHaveCount(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(0)).toHaveText(/blauer e-Golf/);
await expect(page.getByTestId("sessions-entry").nth(1)).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 selectVehicleFilter(page, "weißes Model 3 (2)");
await expect(page.getByTestId("sessions-entry")).toHaveCount(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(0)).toHaveText(/weißes Model 3/);
await expect(page.getByTestId("sessions-entry").nth(1)).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 selectVehicleFilter(page, "all vehicles (4)");
await expect(page.getByTestId("sessions-entry")).toHaveCount(4); await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
}); });
test("by loadpoint", async ({ page }) => { test("by loadpoint", async ({ page }) => {
await page.setViewportSize(viewport); await page.setViewportSize(viewport);
await page.goto("/#/sessions?year=2023&month=5"); await page.goto("/#/sessions?year=2023&month=5");
await expect(page.getByTestId("sessions-entry")).toHaveCount(4); await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
await selectLoadpointFilter(page, "Carport (3)"); await selectLoadpointFilter(page, "Carport (3)");
await expect(page.getByTestId("sessions-entry")).toHaveCount(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(0)).toHaveText(/Carport/);
await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/Carport/); await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/Carport/);
await expect(page.getByTestId("sessions-entry").nth(2)).toHaveText(/Carport/); await expect(page.getByTestId("sessions-entry").nth(2)).toHaveText(/Carport/);
await selectLoadpointFilter(page, "Garage (1)"); await selectLoadpointFilter(page, "Garage (1)");
await expect(page.getByTestId("sessions-entry")).toHaveCount(1); await expect(page.getByTestId("sessions-entry")).toHaveCount(1);
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Garage/); await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Garage/);
await selectLoadpointFilter(page, "all charging points (4)"); await selectLoadpointFilter(page, "all charging points (4)");
await expect(page.getByTestId("sessions-entry")).toHaveCount(4); await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
}); });
test("by vehicle and loadpoint", async ({ page }) => { test("by vehicle and loadpoint", async ({ page }) => {
await page.setViewportSize(viewport); await page.setViewportSize(viewport);
await page.goto("/#/sessions?year=2023&month=5"); await page.goto("/#/sessions?year=2023&month=5");
await selectLoadpointFilter(page, "Carport (3)"); await selectLoadpointFilter(page, "Carport (3)");
await selectVehicleFilter(page, "weißes Model 3 (1)"); await selectVehicleFilter(page, "weißes Model 3 (1)");
await expect(page.getByTestId("sessions-entry")).toHaveCount(1); await expect(page.getByTestId("sessions-entry")).toHaveCount(1);
await expect(page.getByTestId("sessions-entry")).toHaveText(/Carport/); await expect(page.getByTestId("sessions-entry")).toHaveText(/Carport/);
await expect(page.getByTestId("sessions-entry")).toHaveText(/weißes Model 3/); await expect(page.getByTestId("sessions-entry")).toHaveText(/weißes Model 3/);
await selectVehicleFilter(page, "blauer e-Golf (2)"); await selectVehicleFilter(page, "blauer e-Golf (2)");
await expect(page.getByTestId("sessions-entry")).toHaveCount(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(/Carport/);
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/blauer e-Golf/); 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(/Carport/);
await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/blauer e-Golf/); await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/blauer e-Golf/);
}); });
test("by vehicle and loadpoint disabled options", async ({ page }) => { test("by vehicle and loadpoint disabled options", async ({ page }) => {
await page.setViewportSize(viewport); await page.setViewportSize(viewport);
await page.goto("/#/sessions?year=2023&month=5"); await page.goto("/#/sessions?year=2023&month=5");
await selectVehicleFilter(page, "blauer e-Golf (2)"); await selectVehicleFilter(page, "blauer e-Golf (2)");
const option = page const option = page
.getByTestId("filter-loadpoint") .getByTestId("filter-loadpoint")
.locator("visible=true") .locator("visible=true")
.locator("option[value=Garage]"); .locator("option[value=Garage]");
await expect(option).toHaveAttribute("disabled", ""); await expect(option).toHaveAttribute("disabled", "");
await expect(option).toHaveText("Garage (0)"); await expect(option).toHaveText("Garage (0)");
}); });
test("keep filter when paging", async ({ page }) => { test("keep filter when paging", async ({ page }) => {
await page.setViewportSize(viewport); await page.setViewportSize(viewport);
await page.goto("/#/sessions?year=2023&month=5"); await page.goto("/#/sessions?year=2023&month=5");
await selectLoadpointFilter(page, "Carport (3)"); await selectLoadpointFilter(page, "Carport (3)");
await expect(page.getByTestId("sessions-entry")).toHaveCount(3); await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
if (name === "mobile") { if (name === "mobile") {
await page.getByTestId("navigate-next-year-month").click(); await page.getByTestId("navigate-next-year-month").click();
await page.getByTestId("navigate-prev-year-month").click(); await page.getByTestId("navigate-prev-year-month").click();
} else { } else {
await page.getByTestId("navigate-next-month").click(); await page.getByTestId("navigate-next-month").click();
await page.getByTestId("navigate-prev-month").click(); await page.getByTestId("navigate-prev-month").click();
} }
await expect(page.getByTestId("sessions-entry")).toHaveCount(3); await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
}); });
}); });
} }
test.describe("columns desktop", async () => { test.describe("columns desktop", async () => {
test("show vehicle column if multiple different exist", async ({ page }) => { test("show vehicle column if multiple different exist", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5"); await page.goto("/#/sessions?year=2023&month=5");
await expect(page.getByTestId("vehicle")).toBeVisible(); await expect(page.getByTestId("vehicle")).toBeVisible();
}); });
test("show loadpoint column if multiple different exist", async ({ page }) => { test("show loadpoint column if multiple different exist", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5"); await page.goto("/#/sessions?year=2023&month=5");
await expect(page.getByTestId("loadpoint")).toBeVisible(); await expect(page.getByTestId("loadpoint")).toBeVisible();
}); });
test("show co2 column it has values", async ({ page }) => { test("show co2 column it has values", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=3"); await page.goto("/#/sessions?year=2023&month=3");
await expect(page.getByTestId("sessions-head-co2")).toBeVisible(); await expect(page.getByTestId("sessions-head-co2")).toBeVisible();
}); });
test("hide co2 column if it doesnt have values", async ({ page }) => { test("hide co2 column if it doesnt have values", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5"); await page.goto("/#/sessions?year=2023&month=5");
await expect(page.getByTestId("sessions-head-co2")).toHaveCount(0); await expect(page.getByTestId("sessions-head-co2")).toHaveCount(0);
}); });
}); });
const date = new Date(); const date = new Date();
@ -263,81 +261,82 @@ const MONTH = date.getMonth() + 1;
const MONTH_YEAR = new Intl.DateTimeFormat("en", { month: "long", year: "numeric" }).format(date); const MONTH_YEAR = new Intl.DateTimeFormat("en", { month: "long", year: "numeric" }).format(date);
test.describe("csv export", async () => { test.describe("csv export", async () => {
test("total export", async ({ page }) => { test("total export", async ({ page }) => {
await page.goto("/#/sessions?period=total"); await page.goto("/#/sessions?period=total");
await expect(page.getByRole("link", { name: "Download total CSV" })).toHaveAttribute( await expect(page.getByRole("link", { name: "Download total CSV" })).toHaveAttribute(
"href", "href",
"./api/sessions?format=csv&lang=en" "./api/sessions?format=csv&lang=en"
); );
}); });
test("year export", async ({ page }) => { test("year export", async ({ page }) => {
// fixed year // fixed year
await page.goto("/#/sessions?period=year&year=2023"); await page.goto("/#/sessions?period=year&year=2023");
await expect(page.getByRole("link", { name: "Download 2023 CSV" })).toHaveAttribute( await expect(page.getByRole("link", { name: "Download 2023 CSV" })).toHaveAttribute(
"href", "href",
"./api/sessions?format=csv&lang=en&year=2023" "./api/sessions?format=csv&lang=en&year=2023"
); );
// current year // current year
await page.goto(`/#/sessions?period=year`); await page.goto(`/#/sessions?period=year`);
await expect(page.getByRole("link", { name: `Download ${YEAR} CSV` })).toHaveAttribute( await expect(page.getByRole("link", { name: `Download ${YEAR} CSV` })).toHaveAttribute(
"href", "href",
`./api/sessions?format=csv&lang=en&year=${YEAR}` `./api/sessions?format=csv&lang=en&year=${YEAR}`
); );
}); });
test("monthly export", async ({ page }) => { test("monthly export", async ({ page }) => {
await page.goto("/#/sessions?&year=2023&month=5"); await page.goto("/#/sessions?&year=2023&month=5");
await expect(page.getByRole("link", { name: "Download May 2023 CSV" })).toHaveAttribute( await expect(page.getByRole("link", { name: "Download May 2023 CSV" })).toHaveAttribute(
"href", "href",
"./api/sessions?format=csv&lang=en&year=2023&month=5" "./api/sessions?format=csv&lang=en&year=2023&month=5"
); );
// current month // current month
await page.goto(`/#/sessions`); await page.goto(`/#/sessions`);
await expect( await expect(page.getByRole("link", { name: `Download ${MONTH_YEAR} CSV` })).toHaveAttribute(
page.getByRole("link", { name: `Download ${MONTH_YEAR} CSV` }) "href",
).toHaveAttribute("href", `./api/sessions?format=csv&lang=en&year=${YEAR}&month=${MONTH}`); `./api/sessions?format=csv&lang=en&year=${YEAR}&month=${MONTH}`
}); );
});
}); });
test.describe("session details", async () => { test.describe("session details", async () => {
test("show session details (session 5)", async ({ page }) => { test("show session details (session 5)", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5"); await page.goto("/#/sessions?year=2023&month=5");
await page.getByTestId("sessions-entry").nth(0).click(); await page.getByTestId("sessions-entry").nth(0).click();
await expect(page.getByTestId("session-details")).toBeVisible(); await expect(page.getByTestId("session-details")).toBeVisible();
await expect( await expect(
page.getByTestId("session-details").getByRole("heading", { name: "Charging Session" }) page.getByTestId("session-details").getByRole("heading", { name: "Charging Session" })
).toBeVisible(); ).toBeVisible();
await expect(page.getByTestId("session-details-loadpoint")).toContainText("Garage"); 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-vehicle")).toContainText("weißes Model 3");
await expect(page.getByTestId("session-details-date")).toContainText( await expect(page.getByTestId("session-details-date")).toContainText(
["Thu, May 4, 22:00", "Fri, May 5, 06:00"].join("") ["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("5.0 kWh");
await expect(page.getByTestId("session-details-energy")).toContainText("1:00"); 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-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-price")).toContainText("2.50 € 50.0 ct/kWh");
await expect(page.getByTestId("session-details-co2")).toHaveCount(0); await expect(page.getByTestId("session-details-co2")).toHaveCount(0);
await expect(page.getByTestId("session-details-odometer")).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-meter")).toHaveCount(0);
await expect(page.getByTestId("session-details-delete")).toContainText("Delete"); await expect(page.getByTestId("session-details-delete")).toContainText("Delete");
}); });
test("show session details with CO2 data (session 1)", async ({ page }) => { test("show session details with CO2 data (session 1)", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=3"); await page.goto("/#/sessions?year=2023&month=3");
await page.getByTestId("sessions-entry").nth(0).click(); await page.getByTestId("sessions-entry").nth(0).click();
await expect(page.getByTestId("session-details")).toBeVisible(); await expect(page.getByTestId("session-details")).toBeVisible();
await expect(page.getByTestId("session-details-loadpoint")).toContainText("Carport"); 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-vehicle")).toContainText("blauer e-Golf");
await expect(page.getByTestId("session-details-date")).toContainText( await expect(page.getByTestId("session-details-date")).toContainText(
["Wed, Mar 1, 07:00", "Tue, May 2, 12:00"].join("") ["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("10.0 kWh");
await expect(page.getByTestId("session-details-energy")).toContainText("1:00"); 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-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-price")).toContainText("2.00 € 20.0 ct/kWh");
await expect(page.getByTestId("session-details-co2")).toContainText("300 g/kWh"); await expect(page.getByTestId("session-details-co2")).toContainText("300 g/kWh");
}); });
}); });

View file

@ -7,71 +7,71 @@ import { spawn } from "child_process";
import { Transform } from "stream"; import { Transform } from "stream";
function workerPort() { function workerPort() {
const index = parseInt(process.env["TEST_WORKER_INDEX"] ?? "-1"); const index = parseInt(process.env["TEST_WORKER_INDEX"] ?? "-1");
return 12000 + index; return 12000 + index;
} }
function logPrefix() { function logPrefix() {
return `[worker:${process.env["TEST_WORKER_INDEX"]}]`; return `[worker:${process.env["TEST_WORKER_INDEX"]}]`;
} }
function createSteamLog() { function createSteamLog() {
return new Transform({ return new Transform({
transform(chunk: Buffer, _, callback) { transform(chunk: Buffer, _, callback) {
const lines = chunk.toString().split("\n"); const lines = chunk.toString().split("\n");
lines.forEach((line) => { lines.forEach((line) => {
if (line.trim()) log(line); if (line.trim()) log(line);
}); });
callback(); callback();
}, },
}); });
} }
function log(...args: any[]) { function log(...args: any[]) {
console.log(logPrefix(), ...args); console.log(logPrefix(), ...args);
} }
export function simulatorHost() { export function simulatorHost() {
return `localhost:${workerPort()}`; return `localhost:${workerPort()}`;
} }
export function simulatorUrl() { export function simulatorUrl() {
return `http://${simulatorHost()}`; return `http://${simulatorHost()}`;
} }
export function simulatorConfig() { export function simulatorConfig() {
const input = "./tests/simulator.evcc.yaml"; const input = "./tests/simulator.evcc.yaml";
const content = fs.readFileSync(input, "utf8"); const content = fs.readFileSync(input, "utf8");
const result = content.replace(/localhost:7072/g, simulatorHost()); const result = content.replace(/localhost:7072/g, simulatorHost());
const resultName = "simulator.evcc.generated.yaml"; const resultName = "simulator.evcc.generated.yaml";
const resultPath = path.join(os.tmpdir(), resultName); const resultPath = path.join(os.tmpdir(), resultName);
fs.writeFileSync(resultPath, result); fs.writeFileSync(resultPath, result);
return resultPath; return resultPath;
} }
export async function startSimulator() { export async function startSimulator() {
const port = workerPort(); const port = workerPort();
log("starting simulator", { port }); log("starting simulator", { port });
log(`wait until port ${port} is available`); log(`wait until port ${port} is available`);
await waitOn({ resources: [`tcp:${port}`], reverse: true, log: true }); await waitOn({ resources: [`tcp:${port}`], reverse: true, log: true });
const instance = spawn("npm", ["run", "simulator", "--", "--port", port.toString()]); const instance = spawn("npm", ["run", "simulator", "--", "--port", port.toString()]);
const steamLog = createSteamLog(); const steamLog = createSteamLog();
instance.stdout.pipe(steamLog); instance.stdout.pipe(steamLog);
instance.stderr.pipe(steamLog); instance.stderr.pipe(steamLog);
instance.on("exit", (code) => { instance.on("exit", (code) => {
log("simulator terminated", { code, port }); log("simulator terminated", { code, port });
steamLog.end(); steamLog.end();
}); });
await waitOn({ resources: [`${simulatorUrl()}/api/state`], log: true }); await waitOn({ resources: [`${simulatorUrl()}/api/state`], log: true });
} }
export async function stopSimulator() { export async function stopSimulator() {
const port = workerPort(); const port = workerPort();
log("shutting down simulator", { port }); log("shutting down simulator", { port });
await axios.post(`${simulatorUrl()}/api/shutdown`); await axios.post(`${simulatorUrl()}/api/shutdown`);
log(`wait until port ${port} is closed`); log(`wait until port ${port} is closed`);
await waitOn({ resources: [`tcp:localhost:${port}`], reverse: true }); await waitOn({ resources: [`tcp:localhost:${port}`], reverse: true });
} }

View file

@ -3,127 +3,127 @@ import type { Connect, ViteDevServer } from "vite";
import type { ServerResponse } from "http"; import type { ServerResponse } from "http";
let state = { let state = {
site: { site: {
grid: { power: 0 }, grid: { power: 0 },
pv: { power: 0, energy: 0 }, pv: { power: 0, energy: 0 },
battery: { power: 0, soc: 0 }, battery: { power: 0, soc: 0 },
}, },
loadpoints: [{ power: 0, energy: 0, enabled: false, status: "A" }], loadpoints: [{ power: 0, energy: 0, enabled: false, status: "A" }],
vehicles: [{ soc: 0, range: 0 }], vehicles: [{ soc: 0, range: 0 }],
}; };
const loggingMiddleware = ( const loggingMiddleware = (
req: Connect.IncomingMessage, req: Connect.IncomingMessage,
_: ServerResponse, _: ServerResponse,
next: Connect.NextFunction next: Connect.NextFunction
) => { ) => {
console.log(`[simulator] ${req.method} ${req.url}`); console.log(`[simulator] ${req.method} ${req.url}`);
next(); next();
}; };
const stateApiMiddleware = ( const stateApiMiddleware = (
req: Connect.IncomingMessage, req: Connect.IncomingMessage,
res: ServerResponse, res: ServerResponse,
next: Connect.NextFunction next: Connect.NextFunction
) => { ) => {
if (req.method === "POST" && req.originalUrl === "/api/state") { if (req.method === "POST" && req.originalUrl === "/api/state") {
console.log("[simulator] POST /api/state"); console.log("[simulator] POST /api/state");
// @ts-expect-error Property 'body' does not exist on type 'IncomingMessage' // @ts-expect-error Property 'body' does not exist on type 'IncomingMessage'
state = req.body; state = req.body;
res.end(); res.end();
} else if (req.method === "POST" && req.originalUrl === "/api/shutdown") { } else if (req.method === "POST" && req.originalUrl === "/api/shutdown") {
console.log("[simulator] POST /api/shutdown"); console.log("[simulator] POST /api/shutdown");
res.end(); res.end();
process.exit(); process.exit();
} else if (req.originalUrl === "/api/state") { } else if (req.originalUrl === "/api/state") {
res.end(JSON.stringify(state)); res.end(JSON.stringify(state));
} else { } else {
next(); next();
} }
}; };
const openemsMiddleware = ( const openemsMiddleware = (
req: Connect.IncomingMessage, req: Connect.IncomingMessage,
res: ServerResponse, res: ServerResponse,
next: Connect.NextFunction next: Connect.NextFunction
) => { ) => {
const endpoints = { const endpoints = {
"/rest/channel/_sum/GridActivePower": { value: state.site.grid.power }, "/rest/channel/_sum/GridActivePower": { value: state.site.grid.power },
"/rest/channel/_sum/ProductionActivePower": { value: state.site.pv.power }, "/rest/channel/_sum/ProductionActivePower": { value: state.site.pv.power },
"/rest/channel/_sum/EssDischargePower": { value: state.site.battery.power }, "/rest/channel/_sum/EssDischargePower": { value: state.site.battery.power },
"/rest/channel/_sum/EssSoc": { value: state.site.battery.soc }, "/rest/channel/_sum/EssSoc": { value: state.site.battery.soc },
}; };
const endpoint = endpoints[req.originalUrl as keyof typeof endpoints]; const endpoint = endpoints[req.originalUrl as keyof typeof endpoints];
if (req.method === "GET" && endpoint) { if (req.method === "GET" && endpoint) {
console.log("[simulator] GET", req.originalUrl); console.log("[simulator] GET", req.originalUrl);
res.end(JSON.stringify(endpoint)); res.end(JSON.stringify(endpoint));
} else { } else {
next(); next();
} }
}; };
const teslaloggerMiddleware = ( const teslaloggerMiddleware = (
req: Connect.IncomingMessage, req: Connect.IncomingMessage,
res: ServerResponse, res: ServerResponse,
next: Connect.NextFunction next: Connect.NextFunction
) => { ) => {
if (req.method === "GET" && req.originalUrl && req.originalUrl.startsWith("/currentjson/")) { if (req.method === "GET" && req.originalUrl && req.originalUrl.startsWith("/currentjson/")) {
const id = parseInt(req.originalUrl.split("/")[2]); const id = parseInt(req.originalUrl.split("/")[2]);
const vehicle = state.vehicles[id - 1]; const vehicle = state.vehicles[id - 1];
if (!vehicle) { if (!vehicle) {
res.statusCode = 404; res.statusCode = 404;
res.end(JSON.stringify({ error: "Vehicle not found" })); res.end(JSON.stringify({ error: "Vehicle not found" }));
return; return;
} }
const data = { const data = {
battery_level: vehicle.soc, battery_level: vehicle.soc,
battery_range_km: vehicle.range, battery_range_km: vehicle.range,
plugged_in: true, plugged_in: true,
charging: false, charging: false,
odometer: 10000, odometer: 10000,
is_preconditioning: false, is_preconditioning: false,
charge_current_request: 10, charge_current_request: 10,
}; };
res.end(JSON.stringify(data)); res.end(JSON.stringify(data));
} else { } else {
next(); next();
} }
}; };
const shellyMiddleware = ( const shellyMiddleware = (
req: Connect.IncomingMessage, req: Connect.IncomingMessage,
res: ServerResponse, res: ServerResponse,
next: Connect.NextFunction next: Connect.NextFunction
) => { ) => {
// simulate a shelly gen2 switch device api. implement power and energy // simulate a shelly gen2 switch device api. implement power and energy
if (req.originalUrl === "/shelly") { if (req.originalUrl === "/shelly") {
res.end(JSON.stringify({ gen: 2 })); res.end(JSON.stringify({ gen: 2 }));
} else if (req.originalUrl === "/rpc/Shelly.ListMethods") { } else if (req.originalUrl === "/rpc/Shelly.ListMethods") {
res.end(JSON.stringify({ methods: ["Switch.GetStatus"] })); res.end(JSON.stringify({ methods: ["Switch.GetStatus"] }));
} else if (req.originalUrl === "/rpc/Switch.GetStatus?id=0") { } else if (req.originalUrl === "/rpc/Switch.GetStatus?id=0") {
res.end( res.end(
JSON.stringify({ JSON.stringify({
apower: state.site.pv.power, apower: state.site.pv.power,
aenergy: { total: state.site.pv.energy }, aenergy: { total: state.site.pv.energy },
}) })
); );
} else { } else {
next(); next();
} }
}; };
export default () => ({ export default () => ({
name: "api", name: "api",
enforce: "pre", enforce: "pre",
configureServer(server: ViteDevServer) { configureServer(server: ViteDevServer) {
console.log("[simulator] configured"); console.log("[simulator] configured");
return () => { return () => {
server.middlewares.use(loggingMiddleware); server.middlewares.use(loggingMiddleware);
server.middlewares.use(bodyParser.json()); server.middlewares.use(bodyParser.json());
server.middlewares.use(stateApiMiddleware); server.middlewares.use(stateApiMiddleware);
server.middlewares.use(openemsMiddleware); server.middlewares.use(openemsMiddleware);
server.middlewares.use(teslaloggerMiddleware); server.middlewares.use(teslaloggerMiddleware);
server.middlewares.use(shellyMiddleware); server.middlewares.use(shellyMiddleware);
}; };
}, },
}); });

View file

@ -1,15 +1,13 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>evcc Simulator</title>
</head>
<head> <body>
<meta charset="UTF-8" /> <div id="app"></div>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <script type="module" src="./src/main.js"></script>
<title>evcc Simulator</title> </body>
</head> </html>
<body>
<div id="app"></div>
<script type="module" src="./src/main.js"></script>
</body>
</html>

View file

@ -3,10 +3,10 @@ import vue from "@vitejs/plugin-vue";
import api from "./api"; import api from "./api";
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
// @ts-expect-error the in api() declared 'enforce' property is not supported in TypeScript but in JavaScript // @ts-expect-error the in api() declared 'enforce' property is not supported in TypeScript but in JavaScript
api(), api(),
], ],
server: { port: 7072, host: true }, server: { port: 7072, host: true },
}); });

View file

@ -1,33 +1,33 @@
import { expect, type Page, type Locator } from "@playwright/test"; import { expect, type Page, type Locator } from "@playwright/test";
export async function enableExperimental(page: Page): Promise<void> { export async function enableExperimental(page: Page): Promise<void> {
await page.getByTestId("topnavigation-button").click(); await page.getByTestId("topnavigation-button").click();
await page.getByTestId("topnavigation-settings").click(); await page.getByTestId("topnavigation-settings").click();
await page.getByLabel("Experimental 🧪").click(); await page.getByLabel("Experimental 🧪").click();
await page.getByRole("button", { name: "Close" }).click(); await page.getByRole("button", { name: "Close" }).click();
await expect(page.locator(".modal-backdrop")).not.toBeVisible(); await expect(page.locator(".modal-backdrop")).not.toBeVisible();
} }
export async function expectModalVisible(modal: Locator): Promise<void> { export async function expectModalVisible(modal: Locator): Promise<void> {
await expect(modal).toBeVisible(); await expect(modal).toBeVisible();
await expect(modal).toHaveAttribute("aria-hidden", "false"); await expect(modal).toHaveAttribute("aria-hidden", "false");
} }
export async function expectModalHidden(modal: Locator): Promise<void> { export async function expectModalHidden(modal: Locator): Promise<void> {
await expect(modal).not.toBeVisible(); await expect(modal).not.toBeVisible();
await expect(modal).toHaveAttribute("aria-hidden", "true"); await expect(modal).toHaveAttribute("aria-hidden", "true");
} }
export async function editorClear(editor: Locator, iterations = 6): Promise<void> { export async function editorClear(editor: Locator, iterations = 6): Promise<void> {
await editor.locator(".view-line").nth(0).click(); await editor.locator(".view-line").nth(0).click();
for (let i = 0; i < iterations; i++) { for (let i = 0; i < iterations; i++) {
await editor.page().keyboard.press("ControlOrMeta+KeyA", { delay: 10 }); await editor.page().keyboard.press("ControlOrMeta+KeyA", { delay: 10 });
await editor.page().keyboard.press("Backspace", { delay: 10 }); await editor.page().keyboard.press("Backspace", { delay: 10 });
} }
} }
export async function editorPaste(editor: Locator, page: Page, text: string): Promise<void> { export async function editorPaste(editor: Locator, page: Page, text: string): Promise<void> {
await editor.locator(".view-line").nth(0).click(); await editor.locator(".view-line").nth(0).click();
await page.evaluate((text) => navigator.clipboard.writeText(text), text); await page.evaluate((text) => navigator.clipboard.writeText(text), text);
await page.keyboard.press("ControlOrMeta+KeyV", { delay: 50 }); await page.keyboard.press("ControlOrMeta+KeyV", { delay: 50 });
} }