evcc-io/core/metrics/db_history_test.go

134 lines
5.1 KiB
Go

package metrics
import (
"bytes"
"context"
"encoding/csv"
"strings"
"testing"
"time"
"github.com/evcc-io/evcc/util/locale"
"github.com/stretchr/testify/require"
)
func mkSlot(t time.Time, energy, returnEnergy float64) Slot {
return Slot{Start: t, End: t.Add(15 * time.Minute), Energy: energy, ReturnEnergy: returnEnergy}
}
func TestSeriesCSV_HeaderAndLayout(t *testing.T) {
t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
t1 := t0.Add(15 * time.Minute)
series := SeriesCSV{
{Group: PV, Title: "pv", Data: []Slot{mkSlot(t0, 2.5, 0), mkSlot(t1, 3.0, 0)}},
{Group: Battery, Title: "battery-home", Data: []Slot{mkSlot(t0, 0, 0.7800), mkSlot(t1, 0.5300, 0)}},
{Group: Battery, Title: "battery-garage", Data: []Slot{mkSlot(t0, 0, 1.2500), mkSlot(t1, 0, 0)}},
{Group: Grid, Title: "grid", Data: []Slot{mkSlot(t0, 0, 0.4123), mkSlot(t1, 0.1, 0)}},
{Group: Home, Title: "home", Data: []Slot{mkSlot(t0, 0.3661, 0), mkSlot(t1, 0.4, 0)}},
}
var buf bytes.Buffer
require.NoError(t, series.WriteCsv(context.Background(), &buf))
// UTF-8 BOM
require.True(t, bytes.HasPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF}), "expected UTF-8 BOM")
r := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF})))
rows, err := r.ReadAll()
require.NoError(t, err)
require.Len(t, rows, 3, "expected header + 2 data rows")
header := rows[0]
expected := []string{
"time.start", "time.end",
"pv.energy.Wh",
"battery.battery-garage.energy.Wh", "battery.battery-garage.returnEnergy.Wh",
"battery.battery-home.energy.Wh", "battery.battery-home.returnEnergy.Wh",
"grid.energy.Wh", "grid.returnEnergy.Wh",
"home.energy.Wh",
}
require.Equal(t, expected, header, "single-entity groups omit the title level; multi-entity groups include the title")
// time.end = time.start + slot length (15 min in mkSlot)
require.Equal(t, t0.Local().Format("2006-01-02 15:04:05"), rows[1][0])
require.Equal(t, t0.Add(15*time.Minute).Local().Format("2006-01-02 15:04:05"), rows[1][1])
// First data row values (t0) — plain Wh integers. Cols start at index 2.
require.Equal(t, "2500", rows[1][2]) // pv.pv energy
require.Equal(t, "0", rows[1][3]) // battery-garage energy
require.Equal(t, "1250", rows[1][4]) // battery-garage returnEnergy
require.Equal(t, "0", rows[1][5]) // battery-home energy
require.Equal(t, "780", rows[1][6]) // battery-home returnEnergy
require.Equal(t, "0", rows[1][7]) // grid energy
require.Equal(t, "412", rows[1][8]) // grid returnEnergy
require.Equal(t, "366", rows[1][9]) // home energy
}
func TestSeriesCSV_GermanLocale(t *testing.T) {
t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
series := SeriesCSV{
{Group: PV, Title: "pv", Data: []Slot{mkSlot(t0, 2.5, 0)}},
}
var buf bytes.Buffer
ctx := context.WithValue(context.Background(), locale.Locale, "de")
require.NoError(t, series.WriteCsv(ctx, &buf))
s := string(bytes.TrimPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF}))
require.True(t, strings.Contains(s, ";"), "german CSV must use ';' separator")
// Values are plain Wh integers so they're locale-agnostic. We just check
// no decimal point / comma snuck in.
require.True(t, strings.Contains(s, "2500"), "value should be Wh integer")
require.False(t, strings.Contains(s, "2.500"), "no '.' decimal/thousands")
require.False(t, strings.Contains(s, "2,500"), "no ',' decimal/thousands")
}
func TestSeriesCSV_MissingSlotIsEmpty(t *testing.T) {
t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
t1 := t0.Add(15 * time.Minute)
// Two entities, second one only has data for the first timestamp → second
// timestamp must produce an empty cell rather than 0.000.
series := SeriesCSV{
{Group: PV, Title: "a", Data: []Slot{mkSlot(t0, 1, 0), mkSlot(t1, 2, 0)}},
{Group: PV, Title: "b", Data: []Slot{mkSlot(t0, 3, 0)}},
}
var buf bytes.Buffer
require.NoError(t, series.WriteCsv(context.Background(), &buf))
r := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF})))
rows, err := r.ReadAll()
require.NoError(t, err)
require.Len(t, rows, 3)
require.Equal(t, []string{"time.start", "time.end", "pv.a.energy.Wh", "pv.b.energy.Wh"}, rows[0])
// row 1: t0 has both entities (Wh integers); cols start at index 2.
require.Equal(t, "1000", rows[1][2])
require.Equal(t, "3000", rows[1][3])
// row 2: t1 has only entity a
require.Equal(t, "2000", rows[2][2])
require.Equal(t, "", rows[2][3], "missing slot must be empty, not zero")
}
func TestSeriesCSV_BatteryHasReturnEnergyColumn(t *testing.T) {
// Battery is bidirectional, so even a series with zero discharge keeps the
// returnEnergy column.
t0 := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
series := SeriesCSV{
{Group: Battery, Title: "bat", Data: []Slot{mkSlot(t0, 0.5, 0)}},
}
var buf bytes.Buffer
require.NoError(t, series.WriteCsv(context.Background(), &buf))
r := csv.NewReader(bytes.NewReader(bytes.TrimPrefix(buf.Bytes(), []byte{0xEF, 0xBB, 0xBF})))
rows, err := r.ReadAll()
require.NoError(t, err)
require.Equal(t, []string{"time.start", "time.end", "battery.bat.energy.Wh", "battery.bat.returnEnergy.Wh"}, rows[0])
require.Equal(t, "500", rows[1][2])
require.Equal(t, "0", rows[1][3])
}