154 lines
4.2 KiB
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)")
|
|
}
|
|
}
|