evcc-io/tariff/helper_test.go

154 lines
4.2 KiB
Go

package tariff
import (
"errors"
"sync"
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/jinzhu/now"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMergeRatesAfter(t *testing.T) {
clock := clock.NewMock()
clock.Set(now.BeginningOfDay())
rate := func(start int, val float64) api.Rate {
return api.Rate{
Start: clock.Now().Add(time.Duration(start) * time.Hour),
End: clock.Now().Add(time.Duration(start+1) * time.Hour),
Value: val,
}
}
old := api.Rates{rate(1, 1), rate(2, 2)}
new := api.Rates{rate(2, 2), rate(3, 3)}
combined := api.Rates{rate(1, 1), rate(2, 2), rate(3, 3)}
data := util.NewMonitor[api.Rates](time.Hour).WithClock(clock)
data.Set(old)
res, err := data.Get()
require.NoError(t, err)
assert.Equal(t, old, res)
for i, tc := range []struct {
new, expected api.Rates
ts time.Time
}{
{new, combined, clock.Now()},
{new, combined, clock.Now().Add(SlotDuration)},
{nil, combined, clock.Now()},
{nil, combined, clock.Now().Add(SlotDuration)},
{new, combined, clock.Now().Add(time.Hour)},
{new, combined, clock.Now().Add(time.Hour + SlotDuration)},
{new, combined, now.With(clock.Now().Add(time.Hour + 30*time.Minute)).BeginningOfHour()},
{new, combined, now.With(clock.Now().Add(time.Hour + 30*time.Minute)).BeginningOfHour().Add(SlotDuration)},
{new, new, clock.Now().Add(2 * time.Hour)},
{new, new, clock.Now().Add(2*time.Hour + SlotDuration)},
} {
t.Logf("%d. %+v", i+1, tc)
mergeRatesAfter(data, tc.new, tc.ts)
res, err := data.Get()
require.NoError(t, err)
assert.Equal(t, tc.expected, res)
}
}
// TestMergeRatesAfterRefreshesTimestamp asserts that merging without new rates
// keeps the cached rates and un-expires the monitor - this is how solcast keeps
// its forecast alive while fetching is paused outside the from/to window.
func TestMergeRatesAfterRefreshesTimestamp(t *testing.T) {
clock := clock.NewMock()
clock.Set(now.BeginningOfDay())
rates := api.Rates{{
Start: clock.Now().Add(time.Hour),
End: clock.Now().Add(2 * time.Hour),
Value: 1,
}}
data := util.NewMonitor[api.Rates](time.Hour).WithClock(clock)
data.Set(rates)
clock.Add(2 * time.Hour)
_, err := data.Get()
require.ErrorIs(t, err, api.ErrOutdated)
mergeRatesAfter(data, nil, clock.Now().Add(-2*time.Hour))
res, err := data.Get()
require.NoError(t, err)
assert.Equal(t, rates, res)
}
type runner struct {
res error
}
func (r *runner) run(done chan error) {
if r.res == nil {
close(done)
} else {
done <- r.res
}
}
func TestRunOrQError(t *testing.T) {
{
res, err := runOrError(&runner{nil})
require.NoError(t, err)
require.NotNil(t, res)
}
{
res, err := runOrError(&runner{errors.New("foo")})
require.Error(t, err)
require.Nil(t, res)
}
}
// leakRunner mirrors the real tariff run loop: its first update always fails
// (e.g. HTTP 429 at startup) and it reports the error via reportError. With the
// fix in place reportError returns true and run returns; without it the loop
// would block on the hourly tick and keep polling forever.
type leakRunner struct {
exited chan struct{}
}
func (r *leakRunner) run(done chan error) {
defer close(r.exited)
var once sync.Once
for tick := time.Tick(time.Hour); ; <-tick {
if reportError(&once, done, errors.New("initial failure (e.g. HTTP 429)")) {
return
}
// without the fix the goroutine would block on <-tick here and keep
// hitting the API on every tick - exactly the leak we guard against
}
}
// TestRunOrErrorStopsGoroutineOnStartupFailure asserts that when the first
// update fails, runOrError both propagates the error and stops the background
// goroutine instead of leaving it to poll the API indefinitely.
func TestRunOrErrorStopsGoroutineOnStartupFailure(t *testing.T) {
r := &leakRunner{exited: make(chan struct{})}
res, err := runOrError(r)
require.Error(t, err, "startup failure must be propagated")
require.Nil(t, res, "tariff must not be returned when startup fails")
select {
case <-r.exited:
// goroutine returned - no leak
case <-time.After(time.Second):
t.Fatal("run goroutine still alive after startup failure (goroutine leak)")
}
}