Sessions: filter, monthly sums, paging (#8278)

This commit is contained in:
Michael Geers 2023-06-14 08:57:09 +02:00 • committed by GitHub
parent 10ba256787
commit e600d8dc71
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 742 additions and 226 deletions

View file

@ -143,7 +143,7 @@ jobs:
integration:
name: Integration
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' }}
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@v3

View file

@ -54,6 +54,8 @@ make
./evcc
```
### UI development
For frontend development start the Vue toolchain in dev-mode. Open http://127.0.0.1:7071/ to get to the livelreloading development server. It pulls its data from port 7070 (see above).
```sh
@ -61,6 +63,15 @@ npm install
npm run start
```
### Integration tests
We use Playwright for end-to-end integration tests. They start a local evcc instance with different configuration yamls and prefilled databases. To run them, you have to do a local build first.
```sh
make ui build
npm run playwright
```
### Code formatting
We use linters (golangci-lint, Prettier) to keep a coherent source code formatting. It's recommended to use the format-on-save feature of your editor. For VSCode use the [Go](https://marketplace.visualstudio.com/items?itemName=golang.Go), [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) and [Veture](https://marketplace.visualstudio.com/items?itemName=octref.vetur) extension. You can manually reformat your code by running:

View file

@ -6,6 +6,7 @@
data-bs-toggle="dropdown"
aria-expanded="false"
class="btn btn-sm btn-outline-secondary position-relative border-0 menu-button"
data-testid="topnavigation-button"
>
<span
v-if="showBadge"
@ -15,7 +16,11 @@
</span>
<shopicon-regular-menu></shopicon-regular-menu>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="topNavigatonDropdown">
<ul
class="dropdown-menu dropdown-menu-end"
aria-labelledby="topNavigatonDropdown"
data-testid="topnavigation-dropdown"
>
<li>
<router-link class="dropdown-item" to="/sessions">
{{ $t("header.sessions") }}

View file

@ -170,6 +170,11 @@ export default {
year: "numeric",
}).format(date);
},
fmtMonth: function (date, short) {
return new Intl.DateTimeFormat(this.$i18n.locale, {
month: short ? "short" : "long",
}).format(date);
},
fmtDayMonthYear: function (date) {
return new Intl.DateTimeFormat(this.$i18n.locale, {
day: "numeric",

View file

@ -9,7 +9,19 @@ export default function setupRouter(i18n) {
history: createWebHashHistory(),
routes: [
{ path: "/", component: Main, props: true },
{ path: "/sessions", component: ChargingSessions, props: true },
{
path: "/sessions",
component: ChargingSessions,
props: (route) => {
const { month, year, loadpoint, vehicle } = route.query;
return {
month: month ? parseInt(month, 10) : undefined,
year: year ? parseInt(year, 10) : undefined,
loadpointFilter: loadpoint,
vehicleFilter: vehicle,
};
},
},
],
});
router.beforeEach(async () => {

View file

@ -12,126 +12,215 @@
<div class="row">
<main class="col-12">
<div class="d-flex justify-content-start mb-4">
<a
class="btn btn-outline-secondary text-nowrap my-2"
:href="csvHrefLink()"
download="sessions.csv"
<div class="d-flex align-items-baseline justify-content-between my-5">
<router-link
class="d-flex text-decoration-none align-items-center"
:class="{ 'pe-none': !hasPrev, 'text-muted': !hasPrev }"
:to="{ query: { ...$route.query, ...prevYearMonth } }"
>
{{ $t("sessions.downloadCsv") }}
</a>
<shopicon-regular-angledoubleleftsmall
size="s"
class="me-1"
></shopicon-regular-angledoubleleftsmall>
<span class="d-none d-sm-block">{{ prevMonthName }}</span>
<span class="d-block d-sm-none">{{ prevMonthNameShort }}</span>
</router-link>
<h2 class="text-center">{{ headline }}</h2>
<router-link
class="d-flex text-decoration-none align-items-center"
:class="{ 'pe-none': !hasNext, 'text-muted': !hasNext }"
:to="{ query: { ...$route.query, ...nextYearMonth } }"
>
<span class="d-none d-sm-block">{{ nextMonthName }}</span>
<span class="d-block d-sm-none">{{ nextMonthNameShort }}</span>
<shopicon-regular-angledoublerightsmall
size="s"
class="ms-1"
></shopicon-regular-angledoublerightsmall>
</router-link>
</div>
<div v-for="group in sessionsByMonthAndLoadpoint" :key="group.month">
<div class="d-flex align-items-center my-5">
<h2 class="me-4 mb-0">
{{ formatGroupHeadline(group.month) }}
</h2>
<a
class="btn btn-xs btn-outline-secondary text-nowrap"
:href="csvHrefLink(group.month)"
download="sessions.csv"
>
CSV
</a>
</div>
<div v-for="loadpoint in group.loadpoints" :key="loadpoint.name">
<div class="d-flex align-items-baseline mb-3">
<h3 class="me-4 mb-0">
{{ loadpoint.name }}
</h3>
<div class="large">{{ fmtKWh(loadpoint.total) }}</div>
</div>
<ul class="breakdown text-gray d-sm-flex flex-sm-wrap ps-0 mb-2">
<li
v-for="(vehicle, id) in groupedKWh('vehicle', loadpoint.sessions)"
:key="id"
class="breakdown-item"
>
{{ vehicle.name }}: {{ fmtKWh(vehicle.energy) }}
</li>
</ul>
<div class="table-responsive my-3">
<table class="table text-nowrap">
<thead>
<tr>
<th scope="col" class="ps-0">{{ $t("sessions.date") }}</th>
<th scope="col">{{ $t("sessions.vehicle") }}</th>
<th scope="col" class="text-end">
{{ $t("sessions.energy") }}
</th>
<th scope="col" class="text-end">
{{ $t("sessions.solar") }}
</th>
<th scope="col" class="text-end">
{{ $t("sessions.price") }}
</th>
<th scope="col" class="text-end">
{{ $t("sessions.avgPrice") }}
</th>
<th scope="col" class="text-end pe-0">
{{ $t("sessions.co2") }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(session, id) in loadpoint.sessions"
:key="id"
role="button"
@click="showDetails(session.id)"
<div v-if="currentSessions.length === 0" data-testid="sessions-nodata" class="my-5">
<p>{{ $t("sessions.noData") }}</p>
</div>
<div v-else class="table-responsive my-3">
<table class="table text-nowrap">
<thead>
<tr data-testid="sessions-head">
<th scope="col" class="align-top ps-0">
{{ $t("sessions.date") }}
</th>
<th scope="col" class="align-top">
{{ $t("sessions.loadpoint") }}
<label class="position-relative d-block">
<select
:value="loadpointFilter"
class="custom-select"
@change="changeLoadpointFilter"
>
<option
v-for="{
name,
value,
count,
} in loadpointFilterOptions"
:key="value"
:value="value"
:disabled="count === 0"
>
{{ name }} ({{ count }})
</option>
</select>
<span
class="fw-normal text-decoration-underline text-nowrap text-muted pe-none"
>
{{ loadpointFilter || $t("sessions.filter.filter") }}
</span>
</label>
</th>
<th scope="col" class="align-top">
{{ $t("sessions.vehicle") }}
<label class="position-relative d-block">
<select
:value="vehicleFilter"
class="custom-select"
@change="changeVehicleFilter"
>
<option
v-for="{
name,
value,
count,
} in vehicleFilterOptions"
:key="value"
:value="value"
:disabled="count === 0"
>
{{ name }} ({{ count }})
</option>
</select>
<span
class="fw-normal text-decoration-underline text-nowrap text-muted pe-none"
>
{{ vehicleFilter || $t("sessions.filter.filter") }}
</span>
</label>
</th>
<th scope="col" class="align-top text-end">
{{ $t("sessions.energy") }}
<div class="text-muted fw-normal">
{{ fmtKWh(chargedEnergy * 1e3, chargedEnergy >= 1) }}
</div>
</th>
<th
v-if="hasSolarPercentage"
scope="col"
class="align-top text-end"
>
{{ $t("sessions.solar") }}
<div
v-if="solarPercentage != null"
class="text-muted fw-normal"
>
<td class="ps-0">
{{ fmtFullDateTime(new Date(session.created), true) }}
</td>
<td>
{{ session.vehicle }}
</td>
<td class="text-end">
{{
fmtKWh(
session.chargedEnergy * 1e3,
session.chargedEnergy >= 1
)
}}
</td>
<td class="text-end">
<span v-if="session.solarPercentage != null">
{{ fmtNumber(session.solarPercentage, 1) }}%
</span>
<span v-else class="text-muted">-</span>
</td>
<td class="text-end">
<span v-if="session.price != null">
{{ fmtMoney(session.price, currency) }}
{{ fmtCurrencySymbol(currency) }}
</span>
<span v-else class="text-muted">-</span>
</td>
<td class="text-end">
<span v-if="session.pricePerKWh != null">
{{ fmtPricePerKWh(session.pricePerKWh, currency) }}
</span>
<span v-else class="text-muted">-</span>
</td>
<td class="text-end pe-0">
<span v-if="session.co2PerKWh != null">
{{ fmtCo2Medium(session.co2PerKWh) }}
</span>
<span v-else class="text-muted">-</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
{{ fmtNumber(solarPercentage, 1) }}%
</div>
</th>
<th v-if="hasPrice" scope="col" class="align-top text-end">
{{ $t("sessions.price") }}
<div v-if="price != null" class="text-muted fw-normal">
{{ fmtMoney(price, currency) }}
{{ fmtCurrencySymbol(currency) }}
</div>
</th>
<th v-if="hasPrice" scope="col" class="align-top text-end">
{{ $t("sessions.avgPrice") }}
<div v-if="pricePerKWh != null" class="text-muted fw-normal">
{{ fmtPricePerKWh(pricePerKWh, currency) }}
</div>
</th>
<th v-if="hasCo2" scope="col" class="align-top text-end pe-0">
{{ $t("sessions.co2") }}
<div v-if="co2PerKWh != null" class="text-muted fw-normal">
{{ fmtCo2Medium(co2PerKWh) }}
</div>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(session, id) in filteredSessions"
:key="id"
role="button"
data-testid="sessions-entry"
@click="showDetails(session.id)"
>
<td class="ps-0">
{{ fmtFullDateTime(new Date(session.created), true) }}
</td>
<td>
{{ session.loadpoint }}
</td>
<td>
{{ session.vehicle }}
</td>
<td class="text-end">
{{
fmtKWh(
session.chargedEnergy * 1e3,
session.chargedEnergy >= 1
)
}}
</td>
<td v-if="hasSolarPercentage" class="text-end">
<span v-if="session.solarPercentage != null">
{{ fmtNumber(session.solarPercentage, 1) }}%
</span>
<span v-else class="text-muted">-</span>
</td>
<td v-if="hasPrice" class="text-end">
<span v-if="session.price != null">
{{ fmtMoney(session.price, currency) }}
{{ fmtCurrencySymbol(currency) }}
</span>
<span v-else class="text-muted">-</span>
</td>
<td v-if="hasPrice" class="text-end">
<span v-if="session.pricePerKWh != null">
{{ fmtPricePerKWh(session.pricePerKWh, currency) }}
</span>
<span v-else class="text-muted">-</span>
</td>
<td v-if="hasCo2" class="text-end pe-0">
<span v-if="session.co2PerKWh != null">
{{ fmtCo2Medium(session.co2PerKWh) }}
</span>
<span v-else class="text-muted">-</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex mb-5 my-4">
<a
v-if="currentSessions.length"
class="btn btn-outline-secondary text-nowrap me-3"
:href="csvLink"
download
>
{{ $t("sessions.csvMonth", { month: headline }) }}
</a>
<a
v-if="sessions.length"
class="btn btn-outline-secondary text-nowrap"
:href="csvTotalLink"
download
>
{{ $t("sessions.csvTotal") }}
</a>
</div>
</main>
<ChargingSessionModal
:session="selectedSession"
:vehicles="vehicles"
:vehicles="vehiclesObjects"
@session-changed="loadSessions"
/>
</div>
@ -142,6 +231,8 @@
import Modal from "bootstrap/js/dist/modal";
import TopNavigation from "../components/TopNavigation.vue";
import "@h2d2/shopicons/es/bold/arrowback";
import "@h2d2/shopicons/es/regular/angledoubleleftsmall";
import "@h2d2/shopicons/es/regular/angledoublerightsmall";
import formatter from "../mixins/formatter";
import api from "../api";
import store from "../store";
@ -153,31 +244,100 @@ export default {
mixins: [formatter],
props: {
notifications: Array,
month: { type: Number, default: () => new Date().getMonth() + 1 },
year: { type: Number, default: () => new Date().getFullYear() },
loadpointFilter: { type: String, default: "" },
vehicleFilter: { type: String, default: "" },
},
data() {
return { sessions: [], selectedSessionId: undefined };
return {
sessions: [],
selectedSessionId: undefined,
};
},
computed: {
sessionsByMonthAndLoadpoint() {
currentSessions() {
const sessionsWithDefaults = this.sessions.map((session) => {
const loadpoint = session.loadpoint || this.$t("main.loadpoint.fallbackName");
const vehicle = session.vehicle || this.$t("main.vehicle.unknown");
return { ...session, loadpoint, vehicle };
});
const sessionsByMonth = this.groupByMonth(sessionsWithDefaults);
return Object.entries(sessionsByMonth).map(([month, sessions]) => {
const loadpoints = Object.entries(this.groupByLoadpoint(sessions)).map(
([loadpoint, sessionsByLoadpoint]) => {
const total = this.totalKWh(sessionsByLoadpoint);
return { name: loadpoint, total, sessions: sessionsByLoadpoint };
}
);
return { month, loadpoints };
return sessionsWithDefaults.filter((session) => {
const date = new Date(session.created);
return date.getFullYear() === this.year && date.getMonth() + 1 === this.month;
});
},
filteredSessions() {
return this.currentSessions.filter(this.filterByLoadpoint).filter(this.filterByVehicle);
},
vehicleFilterOptions() {
const options = [
{
name: this.$t("sessions.filter.allVehicles"),
value: "",
count: this.filterCountForVehicle(),
},
];
this.vehicles.forEach((name) => {
const count = this.filterCountForVehicle(name);
options.push({ name, value: name, count });
});
return options;
},
loadpointFilterOptions() {
const options = [
{
name: this.$t("sessions.filter.allLoadpoints"),
value: "",
count: this.filterCountForLoadpoint(),
},
];
this.loadpoints.forEach((name) => {
const count = this.filterCountForLoadpoint(name);
options.push({ name, value: name, count });
});
return options;
},
chargedEnergy() {
return this.filteredSessions.reduce((total, s) => total + s.chargedEnergy, 0);
},
price() {
return this.filteredSessions.reduce((total, s) => total + s.price, 0);
},
hasPrice() {
return this.filteredSessions.find((s) => s.price != null) != null;
},
hasSolarPercentage() {
return this.filteredSessions.find((s) => s.solarPercentage != null) != null;
},
hasCo2() {
return this.filteredSessions.find((s) => s.co2PerKWh != null) != null;
},
pricePerKWh() {
return this.price / this.chargedEnergy;
},
co2PerKWh() {
const emittedCo2 = this.filteredSessions.reduce(
(total, s) => total + s.chargedEnergy * s.co2PerKWh,
0
);
return emittedCo2 / this.chargedEnergy;
},
solarPercentage() {
const chargedSolarEnergy = this.filteredSessions.reduce(
(total, s) => total + s.chargedEnergy * (s.solarPercentage / 100),
0
);
return (100 / this.chargedEnergy) * chargedSolarEnergy;
},
loadpoints() {
return [...new Set(this.currentSessions.map((s) => s.loadpoint))];
},
vehicles() {
return [...new Set(this.currentSessions.map((s) => s.vehicle))];
},
vehiclesObjects() {
return (
store.state.vehicles?.map((v, index) => {
return { id: index, title: v };
@ -190,70 +350,108 @@ export default {
currency() {
return store.state.currency;
},
headline() {
const date = new Date();
date.setMonth(this.month - 1);
date.setFullYear(this.year);
return this.fmtMonthYear(date);
},
csvLink() {
return this.csvHrefLink(this.year, this.month);
},
csvTotalLink() {
return this.csvHrefLink();
},
prevDate() {
const date = new Date();
date.setFullYear(this.year);
date.setMonth(this.month - 2);
return date;
},
prevYearMonth() {
return { year: this.prevDate.getFullYear(), month: this.prevDate.getMonth() + 1 };
},
prevMonthName() {
return this.fmtMonth(this.prevDate);
},
prevMonthNameShort() {
return this.fmtMonth(this.prevDate, true);
},
nextDate() {
const date = new Date();
date.setFullYear(this.year);
date.setMonth(this.month);
return date;
},
nextYearMonth() {
return { year: this.nextDate.getFullYear(), month: this.nextDate.getMonth() + 1 };
},
nextMonthName() {
return this.fmtMonth(this.nextDate);
},
nextMonthNameShort() {
return this.fmtMonth(this.nextDate, true);
},
hasNext() {
const now = new Date();
return this.year < now.getFullYear() || this.month < now.getMonth() + 1;
},
hasPrev() {
const length = this.sessions.length;
if (length === 0) {
return false;
}
const first = new Date(this.sessions[length - 1].created);
return this.year > first.getFullYear() || this.month > first.getMonth() + 1;
},
},
mounted() {
this.loadSessions();
},
methods: {
filterByLoadpoint(session) {
return !this.loadpointFilter || session.loadpoint === this.loadpointFilter;
},
filterByVehicle(session) {
return !this.vehicleFilter || session.vehicle === this.vehicleFilter;
},
filterCountForVehicle(vehicle) {
return this.currentSessions
.filter(this.filterByLoadpoint)
.filter((s) => !vehicle || s.vehicle === vehicle).length;
},
filterCountForLoadpoint(loadpoint) {
return this.currentSessions
.filter(this.filterByVehicle)
.filter((s) => !loadpoint || s.loadpoint === loadpoint).length;
},
changeLoadpointFilter(event) {
const loadpoint = event.target.value || undefined;
this.$router.push({ query: { ...this.$route.query, loadpoint } });
},
changeVehicleFilter(event) {
const vehicle = event.target.value || undefined;
this.$router.push({ query: { ...this.$route.query, vehicle } });
},
async loadSessions() {
const response = await api.get("sessions");
this.sessions = response.data?.result;
},
groupByMonth(sessions) {
return sessions.reduce((groups, session) => {
const date = new Date(session.created);
const month = `${date.getFullYear()}.${date.getMonth() + 1}`;
if (!groups[month]) groups[month] = [];
groups[month].push(session);
return groups;
}, {});
},
groupByLoadpoint(sessions) {
return sessions.reduce((groups, session) => {
const loadpoint = session.loadpoint;
if (!groups[loadpoint]) groups[loadpoint] = [];
groups[loadpoint].push(session);
return groups;
}, {});
},
totalKWh(sessions) {
return sessions.reduce((total, session) => total + session.chargedEnergy, 0) * 1e3;
},
groupedKWh(by, sessions) {
const grouped = sessions.reduce((groups, session) => {
const name = session[by];
if (!groups[name]) groups[name] = 0;
groups[name] += session.chargedEnergy * 1e3;
return groups;
}, {});
const list = Object.entries(grouped).map(([name, energy]) => {
return { name, energy };
});
return list.length >= 2 ? list : [];
},
formatGroupHeadline(group) {
const date = new Date();
const [year, month] = group.split(".");
date.setMonth(month - 1);
date.setFullYear(year);
return this.fmtMonthYear(date);
},
showDetails(sessionId) {
this.selectedSessionId = sessionId;
const modal = Modal.getOrCreateInstance(document.getElementById("sessionDetailsModal"));
modal.show();
},
csvHrefLink(groupKey) {
var url = `./api/sessions?format=csv&lang=${this.$i18n.locale}`;
if (groupKey) {
const [year, month] = groupKey.split(".");
url += `&year=${year}`;
if (month) {
url += `&month=${month}`;
}
csvHrefLink(year, month) {
const params = new URLSearchParams({
format: "csv",
lang: this.$i18n.locale,
});
if (year && month) {
params.append("year", year);
params.append("month", month);
}
return url;
return `./api/sessions?${params.toString()}`;
},
},
};
@ -265,27 +463,13 @@ export default {
position: relative;
top: -2px;
}
.breakdown {
list-style: none;
}
.breakdown-item {
white-space: nowrap;
}
/* breakpoint sm */
@media (min-width: 576px) {
.breakdown-item:after {
content: ", ";
white-space: wrap;
margin-right: 0.25rem;
}
.breakdown-item:last-child:after {
content: "";
}
}
.breakdown:empty {
display: none;
.custom-select {
left: 0;
top: 0;
bottom: 0;
right: 0;
position: absolute;
opacity: 0;
-webkit-appearance: menulist-button;
}
</style>

View file

@ -285,10 +285,13 @@ title = "Ladevorgang"
[sessions]
avgPrice = "Ø Preis"
co2 = "Ø CO₂"
csvMonth = "Download {month} CSV"
csvTotal = "Download total CSV"
date = "Anfang"
downloadCsv = "Als CSV herunterladen"
energy = "Geladen"
loadpoint = "Ladepunkt"
noData = "Noch keine Ladevorgänge in diesem Monat."
price = "Σ Preis"
reallyDelete = "Wollen Sie diesen Ladevorgang wirklich löschen?"
solar = "Sonne"
@ -306,6 +309,11 @@ meterstop = "Endzählerstand (kWh)"
odometer = "Kilometerstand (km)"
vehicle = "Fahrzeug"
[sessions.filter]
allLoadpoints = "Alle Ladepunkte"
allVehicles = "Alle Fahrzeuge"
filter = "filtern"
[settings]
title = "Einstellungen"

View file

@ -285,10 +285,13 @@ title = "Charging Session"
[sessions]
avgPrice = "Ø Price"
co2 = "Ø CO₂"
csvMonth = "Download {month} CSV"
csvTotal = "Download total CSV"
date = "Finished"
downloadCsv = "Download as CSV"
energy = "Charged"
loadpoint = "Charging point"
noData = "No charging sessions this month."
price = "Σ Price"
reallyDelete = "Do you really want to delete this session?"
solar = "Solar"
@ -306,6 +309,11 @@ meterstop = "Meter stop (kWh)"
odometer = "Mileage (km)"
vehicle = "Vehicle"
[sessions.filter]
allLoadpoints = "all charging points"
allVehicles = "all vehicles"
filter = "filter"
[settings]
title = "Settings"

81
package-lock.json generated
View file

@ -43,7 +43,8 @@
"vue-hot-reload-api": "^2.3.4",
"vue-i18n": "^9.2.2",
"vue-meta": "^3.0.0-alpha.2",
"vue-router": "^4.0.12"
"vue-router": "^4.0.12",
"wait-on": "^7.0.1"
},
"engines": {
"node": ">=18.0.0",
@ -2082,6 +2083,19 @@
"resolved": "https://registry.npmjs.org/@h2d2/shopicons/-/shopicons-1.8.1.tgz",
"integrity": "sha512-vnL8GncQJp+Pqv0yFpIcGvRNzaAXeZDT28tM427dy/e1IShafVNnB9fi9b4sbqlKwsg875vQSq7zUgm36oS9TA=="
},
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
"integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="
},
"node_modules/@hapi/topo": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz",
"integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==",
"dependencies": {
"@hapi/hoek": "^9.0.0"
}
},
"node_modules/@histoire/app": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@histoire/app/-/app-0.16.1.tgz",
@ -2401,6 +2415,24 @@
"node": ">= 8.0.0"
}
},
"node_modules/@sideway/address": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz",
"integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==",
"dependencies": {
"@hapi/hoek": "^9.0.0"
}
},
"node_modules/@sideway/formula": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz",
"integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg=="
},
"node_modules/@sideway/pinpoint": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
"integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="
},
"node_modules/@toml-tools/lexer": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@toml-tools/lexer/-/lexer-0.3.1.tgz",
@ -5372,6 +5404,18 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/joi": {
"version": "17.9.2",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.9.2.tgz",
"integrity": "sha512-Itk/r+V4Dx0V3c7RLFdRh12IOjySm2/WGPMubBT92cQvRfYZhPM2W0hZlctjj72iES8jsRCwp7S/cRmWBnJ4nw==",
"dependencies": {
"@hapi/hoek": "^9.0.0",
"@hapi/topo": "^5.0.0",
"@sideway/address": "^4.1.3",
"@sideway/formula": "^3.0.1",
"@sideway/pinpoint": "^2.0.0"
}
},
"node_modules/js-beautify": {
"version": "1.14.6",
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.14.6.tgz",
@ -6596,6 +6640,14 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@ -7615,6 +7667,33 @@
"node": ">=14"
}
},
"node_modules/wait-on": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.0.1.tgz",
"integrity": "sha512-9AnJE9qTjRQOlTZIldAaf/da2eW0eSRSgcqq85mXQja/DW3MriHxkpODDSUEg+Gri/rKEcXUZHe+cevvYItaog==",
"dependencies": {
"axios": "^0.27.2",
"joi": "^17.7.0",
"lodash": "^4.17.21",
"minimist": "^1.2.7",
"rxjs": "^7.8.0"
},
"bin": {
"wait-on": "bin/wait-on"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/wait-on/node_modules/axios": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
"dependencies": {
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",

View file

@ -48,7 +48,8 @@
"vue-hot-reload-api": "^2.3.4",
"vue-i18n": "^9.2.2",
"vue-meta": "^3.0.0-alpha.2",
"vue-router": "^4.0.12"
"vue-router": "^4.0.12",
"wait-on": "^7.0.1"
},
"engines": {
"npm": ">=8.0.0",

View file

@ -5,22 +5,13 @@ const { defineConfig, devices } = require("@playwright/test");
*/
module.exports = defineConfig({
testDir: "./tests",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
workers: 1, // run testfiles serially to avoid port and database conflicts
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1:7070",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
projects: [

View file

@ -1,14 +1,11 @@
const { test, expect } = require("@playwright/test");
const { execEvcc } = require("./utils");
const { start, stop } = require("./evcc");
let server;
test.beforeAll(() => {
server = execEvcc("basics.evcc.yaml");
test.beforeAll(async () => {
await start("basics.evcc.yaml");
});
test.afterAll(() => {
server.kill();
test.afterAll(async () => {
await stop();
});
test.beforeEach(async ({ page }) => {

40
tests/evcc.js Normal file
View file

@ -0,0 +1,40 @@
import fs from "fs";
import waitOn from "wait-on";
import axios from "axios";
import { exec, execSync } from "child_process";
import playwrightConfig from "../playwright.config";
const BASE_URL = playwrightConfig.use.baseURL;
const DB_PATH = "./evcc.db";
const BINARY = "./evcc";
export async function start(config, database) {
clean();
if (database) {
console.log("loading database", { database });
execSync(`sqlite3 ${DB_PATH} < tests/${database}`);
}
console.log("starting evcc", { config });
const instance = exec(`EVCC_DATABASE_DSN=${DB_PATH} ${BINARY} --config tests/${config}`);
instance.stdout.pipe(process.stdout);
instance.on("exit", (code) => {
if (code !== 0) {
throw new Error("evcc terminated", code);
}
});
await waitOn({ resources: [BASE_URL] });
}
export async function stop() {
console.log("shutting down evcc");
await axios.post(BASE_URL + "/api/shutdown");
clean();
}
function clean() {
if (fs.existsSync(DB_PATH)) {
console.log("delete database", DB_PATH);
fs.unlinkSync(DB_PATH);
}
}

159
tests/sessions.spec.js Normal file
View file

@ -0,0 +1,159 @@
const { test, expect } = require("@playwright/test");
const { start, stop } = require("./evcc");
test.beforeAll(async () => {
await start("basics.evcc.yaml", "sessions.sql");
});
test.afterAll(async () => {
await stop();
});
test.describe("basics", async () => {
test("navigation to sessions", async ({ page }) => {
await page.goto("/");
const topNavigationButton = await page.getByTestId("topnavigation-button");
await topNavigationButton.click();
await page
.getByTestId("topnavigation-dropdown")
.getByRole("link", { name: "Charging sessions" })
.click();
await expect(page.getByRole("heading", { name: "Charging Sessions" })).toBeVisible();
});
test("month without data", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=6");
await expect(page.getByTestId("sessions-nodata")).toHaveCount(1);
await expect(page.getByTestId("sessions-entry")).toHaveCount(0);
});
test("month with data", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5");
await expect(page.getByRole("heading", { name: "MAY 2023" })).toBeVisible();
await expect(page.getByTestId("sessions-nodata")).toHaveCount(0);
await expect(page.getByRole("table")).toBeVisible();
await expect(page.getByTestId("sessions-head")).toHaveCount(1);
await expect(page.getByText("Charged 20.0 kWh")).toBeVisible();
await expect(page.getByText("Solar 67.3%")).toBeVisible();
await expect(page.getByText("Σ Price 5.50 €")).toBeVisible();
await expect(page.getByText("Ø Price 27.5 ct/kWh")).toBeVisible();
await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
});
});
test.describe("paging", async () => {
test("prev/next links", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5");
await expect(page.getByRole("link", { name: "April" })).toBeVisible();
await expect(page.getByRole("link", { name: "June" })).toBeVisible();
});
test("next month", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5");
await page.getByRole("link", { name: "June" }).click();
await expect(page.getByRole("heading", { name: "JUNE 2023" })).toBeVisible();
await expect(page.getByRole("link", { name: "May" })).toBeVisible();
await expect(page.getByRole("link", { name: "July" })).toBeVisible();
});
test("prev month", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=6");
await page.getByRole("link", { name: "May" }).click();
await expect(page.getByRole("heading", { name: "MAY 2023" })).toBeVisible();
});
});
test.describe("filter", async () => {
test("by vehicle", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5");
await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
await page.getByLabel("all vehicles").selectOption("blauer e-Golf (2)");
await expect(page.getByTestId("sessions-entry")).toHaveCount(2);
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/blauer e-Golf/);
await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/blauer e-Golf/);
await page.getByLabel("all vehicles").selectOption("weißes Model 3 (2)");
await expect(page.getByTestId("sessions-entry")).toHaveCount(2);
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/weißes Model 3/);
await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/weißes Model 3/);
await page.getByLabel("all vehicles").selectOption("all vehicles (4)");
await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
});
test("by loadpoint", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5");
await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
await page.getByLabel("all charging points").selectOption("Carport (3)");
await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Carport/);
await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/Carport/);
await expect(page.getByTestId("sessions-entry").nth(2)).toHaveText(/Carport/);
await page.getByLabel("all charging points").selectOption("Garage (1)");
await expect(page.getByTestId("sessions-entry")).toHaveCount(1);
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Garage/);
await page.getByLabel("all charging points").selectOption("all charging points (4)");
await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
});
test("by vehicle and loadpoint", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5");
await page.getByLabel("all charging points").selectOption("Carport (3)");
await page.getByLabel("all vehicles").selectOption("weißes Model 3 (1)");
await expect(page.getByTestId("sessions-entry")).toHaveCount(1);
await expect(page.getByTestId("sessions-entry")).toHaveText(/Carport/);
await expect(page.getByTestId("sessions-entry")).toHaveText(/weißes Model 3/);
await page.getByLabel("all vehicles").selectOption("blauer e-Golf (2)");
await expect(page.getByTestId("sessions-entry")).toHaveCount(2);
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Carport/);
await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/blauer e-Golf/);
await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/Carport/);
await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/blauer e-Golf/);
});
test("by vehicle and loadpoint disabled options", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5");
await page.getByLabel("all vehicles").selectOption("blauer e-Golf (2)");
const option = page.locator("option[value=Garage]");
await expect(option).toHaveAttribute("disabled", "");
await expect(option).toHaveText("Garage (0)");
});
test("keep filter when paging", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5");
await page.getByLabel("all charging points").selectOption("Carport (3)");
await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
await page.getByRole("link", { name: "June" }).click();
await page.getByRole("link", { name: "May" }).click();
await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
});
});
test.describe("csv export", async () => {
test("total export", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5");
await expect(page.getByRole("link", { name: "Download total CSV" })).toHaveAttribute(
"href",
"./api/sessions?format=csv&lang=en"
);
await page.goto("/#/sessions?year=2023&month=6");
await expect(page.getByRole("link", { name: "Download total CSV" })).toHaveAttribute(
"href",
"./api/sessions?format=csv&lang=en"
);
});
test("monthly export", async ({ page }) => {
await page.goto("/#/sessions?year=2023&month=5");
await expect(page.getByRole("link", { name: "Download May 2023 CSV" })).toHaveAttribute(
"href",
"./api/sessions?format=csv&lang=en&year=2023&month=5"
);
await page.goto("/#/sessions?year=2023&month=6");
await expect(page.getByRole("link", { name: "Download June 2023 CSV" })).toHaveCount(0);
});
});

23
tests/sessions.sql Normal file
View file

@ -0,0 +1,23 @@
DROP TABLE IF EXISTS `sessions`;
CREATE TABLE `sessions` (
`id` integer,
`created` datetime,
`finished` datetime,
`loadpoint` text,
`identifier` text,
`vehicle` text,
`meter_start_kwh` real,
`meter_end_kwh` real,
`charged_kwh` real,
`odometer` real,
`solar_percentage` real,
`price` real,
`price_per_kwh` real,
`co2_per_kwh` real,
PRIMARY KEY (`id`)
);
INSERT INTO `sessions` VALUES (1,'2023-05-02 08:00:00.0+02:00','2023-05-02 12:00:00.0+02:00','Carport',NULL,'blauer e-Golf',NULL,NULL,10,NULL,100,2,0.2,NULL);
INSERT INTO `sessions` VALUES (2,'2023-05-02 08:00:00.0+02:00','2023-05-02 12:00:00.0+02:00','Carport',NULL,'blauer e-Golf',NULL,NULL,2.5,NULL,88.21,0.75,0.3,NULL);
INSERT INTO `sessions` VALUES (3,'2023-05-03 16:00:00.0+02:00','2023-05-03 20:00:00.0+02:00','Carport',NULL,'weißes Model 3',NULL,NULL,2.5,NULL,50,0.25,0.1,NULL);
INSERT INTO `sessions` VALUES (4,'2023-05-04 22:00:00.0+02:00','2023-05-05 06:00:00.0+02:00','Garage',NULL,'weißes Model 3',NULL,NULL,5,NULL,0,2.5,0.5,NULL);

View file

@ -1,7 +0,0 @@
import { exec } from "child_process";
export function execEvcc(config) {
const server = exec(`./evcc --config tests/${config}`);
server.stdout.pipe(process.stdout);
return server;
}